Compare commits

..

No commits in common. "main" and "timmy/1411-progressive-my-work-live-recovery" have entirely different histories.

94 changed files with 329 additions and 9331 deletions

View File

@ -121,27 +121,6 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
review, Filed item, or update records its canonical detail route locally before navigation and marks
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
merge the server list without duplicate routes, while each confirmed account remains bounded to its
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
private-state key; stale responses from a prior account are discarded. Set
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
@ -321,7 +300,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
@ -872,23 +851,3 @@ Checkpoint replacement uses writer-unique, flushed temporary files and an
atomic rename, preventing concurrent writers from colliding or exposing partial
JSON. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
## Human Gates inbox
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
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 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
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
and Telegram notifications expose count and route only, using
`#/my-work/human-gates`; they never expose project, title, candidate hash,
artifacts, checks, provenance, reasons, or receipts.

View File

@ -1,45 +0,0 @@
# Human Gates producer and notification contract
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers.
## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
```json
{
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": "abc123",
"title": "Dashboard candidate",
"priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": "success", "required": true}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"}
}
```
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
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`.
Web Push, Telegram, and other lock-screen adapters MUST expose **count and route only**:
```json
{
"pending_count": 3,
"route": "#/my-work/human-gates"
}
```
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.

View File

@ -44,14 +44,9 @@ function createContextPoller({
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
const degradedRetrySeconds = (sectionValues || [])
.filter(section => section.degraded)
.map(section => Number(section.retry_in_seconds ?? freshness.retry_in_seconds))
.filter(seconds => Number.isFinite(seconds) && seconds > 0);
const failedSectionDelay = degradedRetrySeconds.length ?
Math.min(...degradedRetrySeconds) * 1000 : null;
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
return failedSectionDelay || intervalMs;
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
@ -60,10 +55,10 @@ function createContextPoller({
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
const healthyDelay = earliestDeadline <= 0 &&
sectionValues.some(section => !section.degraded && section.revalidating) ?
intervalMs : Math.max(0, earliestDeadline) * 1000;
return failedSectionDelay === null ? healthyDelay : Math.min(healthyDelay, failedSectionDelay);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
}
return intervalMs;
}
@ -126,7 +121,7 @@ function createContextPoller({
let request;
try {
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
request = fetchContext({ ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}

View File

@ -280,15 +280,6 @@ textarea { resize: vertical; min-height: 120px; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.progressive-work-detail { position:fixed; inset:0; z-index:120; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.progressive-work-detail[hidden] { display:none; }
.progressive-work-detail-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:calc(18px + env(safe-area-inset-top)) 18px calc(18px + env(safe-area-inset-bottom)); border:1px solid #55d6be; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.progressive-work-detail-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.progressive-work-detail-panel h2, .progressive-work-detail-panel p { margin-top:0; }
.progressive-work-detail-panel button, .progressive-work-detail-panel .button-link { box-sizing:border-box; min-height:44px; }
.progressive-work-detail-panel .button-link { display:flex; align-items:center; justify-content:center; width:100%; }
@media(max-width:320px) { .progressive-work-detail-panel { width:100%; padding-inline:14px; } }
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.plan-today-sheet[hidden] { display:none; }
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
@ -888,11 +879,8 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.current-work-pin { display:none; }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
@media (max-width:600px) {
[data-current-work-pin] { display:inline-flex; align-items:center; justify-content:center; min-width:64px; min-height:44px; padding-inline:12px; }
[data-current-work-pin="unpin"] { border-color:#60a5fa; background:#17365a; color:#fff; }
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
position:sticky; top:env(safe-area-inset-top); z-index:6;
@ -1499,7 +1487,7 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .review-inline-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
@ -1512,32 +1500,9 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-first-task-actions button:disabled { opacity:.55; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { box-sizing:border-box; max-height:100dvh; overflow-y:auto; overscroll-behavior:contain; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.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; }
.mobile-queue-all summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.mobile-queue-list [data-unavailable="true"] { border-color:#f59e0b; }
.mobile-queue-list [data-unavailable="true"]::after { content:'Sync unavailable · open to retry'; color:#fbbf24; font-size:.75rem; }
.mobile-queue-list [data-progressive-loading="true"]::after { content:'Still loading · available after workspace starts'; }
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
@ -1549,16 +1514,12 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
.mobile-start-day h3, .mobile-start-day p { margin:0; }
.mobile-start-day p + p { margin-top:4px; }
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
.mobile-pinned-work-toggle { min-height:44px; width:100%; margin-top:8px; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
@ -1642,10 +1603,3 @@ textarea { resize: vertical; min-height: 120px; }
.create-pull-mode label{display:flex;align-items:center;gap:7px}
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-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)}}

View File

@ -3,9 +3,6 @@
await workspaceLifecycle.optionalReady;
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
const progressiveMobileDockHandoff = window.stackchainProgressiveMobileDock?.handoff?.();
window.stackchainProgressiveMobileDock?.stop?.();
window.stackchainProgressiveMyWork?.stop();
const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message;
@ -112,10 +109,6 @@
}
let queueCounts = {};
let preparationItems = {};
let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {};
let mobileQueuePriority = null;
let mobileRecentWork = { record:() => false, render:() => 0 };
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
@ -125,15 +118,11 @@
queueCounts.following = count;
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'),
@ -174,7 +163,6 @@
}
const mobileQueueLauncher = createMobileQueueLauncher({
openDelivery: () => mobileDeliveryRecovery.open(),
openHumanGates: () => openHumanGates(),
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
@ -184,25 +172,8 @@
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])
),
nextAction: qs('#mobile-queue-next-action'),
activeList: qs('#mobile-queue-active-list'),
planningList: qs('#mobile-queue-planning-list'),
allList: qs('#mobile-queue-all-list'),
activeSection: qs('#mobile-queue-active-list').parentElement,
});
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
function openMobileStartDay() {
followingQueue.load().catch(() => {});
const state = mobileStartDay.state();
@ -210,7 +181,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-queue-next-action').focus();
qs('#mobile-start-day-action').focus();
}
const mobileFirstTask = createMobileFirstTask({
getLogin: () => confirmedOwnerLogin,
@ -249,20 +220,17 @@
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) sheet.showModal();
qs('#mobile-queue-next-action').focus();
qs('#mobile-start-day-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();
renderMobileQueuePresentation();
qs('#finish-mobile-start-day').addEventListener('click', () => {
mobileStartDay.finish();
renderMobileQueuePresentation();
});
qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish());
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
mobileStartDay.render();
@ -314,7 +282,7 @@
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
queues: () => refreshTomorrowQueueSummary(),
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
@ -323,12 +291,6 @@
},
});
mobileTaskDock.start();
if (['work', 'queues'].includes(progressiveMobileDockHandoff?.lastTask)) {
mobileTaskDock.select(progressiveMobileDockHandoff.lastTask);
}
if (progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open) {
qs('#mobile-queue-sheet').showModal();
}
const mobileTodayActions = qs('#mobile-today-actions');
createMobileTodayCommandBar({
more:qs('[data-mobile-today-more]'),
@ -359,7 +321,7 @@
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
let initialAccountRecovery = Promise.resolve(false);
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'];
@ -430,49 +392,7 @@
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
mobileRecentWork = createMobileRecentWork({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
fetchJson:fetchReviewJson,
document,
section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'),
pinnedSection:qs('#mobile-pinned-work'),
pinnedList:qs('#mobile-pinned-work-list'),
pinnedToggle:qs('#mobile-pinned-work-toggle'),
detailPins:qsa('[data-current-work-pin]'),
status:qs('#mobile-recent-work-status'),
openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
if (window.location.hash !== fragment) window.history.pushState({ workRoute:fragment }, '', fragment);
workRoute.sync();
},
});
mobileRecentWork.startLifecycle({window, document});
mobileQueuePriority = createMobileQueuePriority({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
fetchJson: fetchReviewJson,
document,
list: qs('#mobile-queue-priority-list'),
resetButton: qs('#reset-mobile-queue-priority'),
status: qs('#mobile-queue-priority-status'),
conflict: qs('#mobile-queue-priority-conflict'),
keepLocalButton: qs('#keep-local-mobile-queue-priority'),
useRemoteButton: qs('#use-remote-mobile-queue-priority'),
labels: {
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
},
onChange: () => {
renderMobileQueuePresentation();
},
});
mobileQueuePriority.start();
mobileQueuePriority.startLifecycle({window, document});
let rR = null;
function rRC() {
if (rR) return rR;
@ -705,7 +625,6 @@
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');
@ -715,70 +634,6 @@
return payload;
}
const humanGatesOnChange = (snapshot, state)=>{
queueCounts.gate = snapshot.pending_count;
appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true);
queueCounts.gateUnavailable = state.available === false;
preparationItems.gate = snapshot.items;
mobileTaskDock.updateQueues(queueCounts);
if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']});
mobileStartDay.render();
};
const humanGates = progressiveHumanGatesHandoff?.controller || createHumanGates({
storage:localStorage,
getLogin:()=>planningOwnerLogin,
getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine,
location:window.location,
fetchJson:fetchReviewJson,
onChange:humanGatesOnChange,
nodes:{
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);
humanGates.setOnChange?.(humanGatesOnChange);
const openHumanGates = () => humanGates.open().catch(error => {
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
});
if (!progressiveHumanGatesHandoff) {
qs('#open-human-gates').addEventListener('click', openHumanGates);
qs('#close-human-gates').addEventListener('click', () => {
qs('#human-gates').hidden = true;
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
});
qs('#human-gates-list').addEventListener('click', event => {
const card = event.target.closest('[data-human-gate-id]');
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 => {
if (!error?.targetSelector) qs('#human-gates-status').textContent = error.message;
});
});
}
if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {});
if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates();
function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight;
@ -2082,8 +1937,6 @@
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
await openRoutedWorkSection(item);
mobileRecentWork.record(item);
mobileRecentWork.setCurrent(item);
const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
},
@ -2114,7 +1967,6 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
mobileRecentWork.setCurrent(null);
issueVoiceReply.cancel();
pullVoiceReply.cancel();
updateVoiceReply.cancel();
@ -3597,14 +3449,8 @@
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
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),
@ -3625,7 +3471,6 @@
if (element) element.textContent = count;
});
queueCounts = counts;
mobileQueueLauncher.renderPresentation();
mobileStartDay.reconcile({
authoritative:authoritativeMyWorkRefresh,
authoritativePhases:['delivery'],
@ -5669,13 +5514,6 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
planningOwnerLogin = activeFlushLogin;
planningOwnerAccountKey = snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
mobileQueuePriority.render();
renderMobileQueuePresentation();
void mobileRecentWork.load();
void mobileQueuePriority.load();
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
@ -5688,17 +5526,11 @@
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
timerView.render();
updatePlanningAvailability();
if (planningOwnerLogin) {
syncPendingTomorrow();
todaySync.migrate(todayWork.read());
initialAccountRecovery = Promise.all([
initialAccountRecovery,
todaySync.flush(),
]).then(() => true);
todaySync.flush();
laterSync.migrate(laterWork.read());
laterSync.flush();
}
@ -7997,7 +7829,6 @@
}
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) {
@ -8010,12 +7841,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 ?
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
interruptionPrompt.restore();
updatePlanningAvailability();
syncPendingTomorrow();
@ -8504,8 +8331,7 @@
});
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) {
pushControllerReady = (workspaceLifecycle.serviceWorkerReady ||
navigator.serviceWorker.register('service-worker.js')).then(async () => {
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
await issueCaptureFeatures.load('push-notifications');
const controller = createPushNotifications({
control:qs('#push-updates'),
@ -8520,8 +8346,6 @@
startDayHour:qs('#push-start-day-hour'),
followingControl:qs('#push-following'),
followingStatus:qs('#push-following-status'),
humanGateControl:qs('#push-human-gates'),
humanGateStatus:qs('#push-human-gates-status'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
@ -8629,30 +8453,6 @@
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
}
if (!adoptedProgressiveSnapshot) await load();
else if (!confirmedOwnerLogin) {
await contextPoller.refresh({ force:true, full:true });
if (!confirmedOwnerLogin) {
try {
const identity = await fetchReviewJson('api/v1/background-identity');
const login = String(identity?.login || '').trim();
if (login) {
confirmedOwnerLogin = login;
planningOwnerLogin = login;
initialAccountRecovery = timerView.restore(todaySync.flush());
}
} catch (_error) {}
}
}
if (progressiveWorkHandoff?.openWork) {
const progressiveItem = lastMyWork.find(item =>
item.repository === progressiveWorkHandoff.openWork.repository &&
Number(item.number) === Number(progressiveWorkHandoff.openWork.number));
if (progressiveItem) {
openRoutedWork(progressiveItem, null);
qs('#progressive-work-detail').hidden = true;
}
}
await appShortcut.run();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
@ -8661,8 +8461,4 @@
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
await initialAccountRecovery;
if (planningOwnerLogin) await todaySync.flush();
await timerView.restore(Promise.resolve(true));
workspaceLifecycle.markWorkspaceReady?.();
})();

View File

@ -1,510 +0,0 @@
function createHumanGates(options = {}) {
const storage = options.storage || window.localStorage;
const getLogin = options.getLogin || (() => '');
const getAccountKey = options.getAccountKey || getLogin;
const isOnline = options.isOnline || (() => navigator.onLine);
const location = options.location || window.location;
const fetchJson = options.fetchJson;
const nodes = options.nodes || {};
let queue = { pending_count: 0, items: [] };
let reviewSnapshot = [];
let reviewIndex = -1;
let loadedAccountKey = '';
let loadEpoch = 0;
const decisionKeys = new Map();
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;',
})[character]);
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);
function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
}
function restore() {
if (!getLogin()) return null;
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
}
function save(value) {
if (!getLogin()) return;
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
}
function restoreProgress(item) {
if (!getLogin() || !item) return null;
try {
const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
return value;
} catch (_) { return null; }
}
function saveProgress(values = {}) {
const item = current();
if (!getLogin() || !item) return false;
const checklist = values.checklist || {};
const progress = {
gate_id:item.id, revision:item.revision,
checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
};
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
}
function restorePendingDecision(item = null) {
if (!getLogin()) return null;
try {
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
return value;
} catch (_) { return null; }
}
function savePendingDecision(value) {
storage.setItem(decisionStorageKey(), JSON.stringify(value));
}
function clearPendingDecision(item) {
if (!restorePendingDecision(item)) return;
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
}
function valuesFromDetail() {
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
return {
checklist,
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
};
}
function updateReadiness(values = valuesFromDetail()) {
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => values.checklist?.[key] === true).length;
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
}
function captureProgress() {
if (!nodes.detail?.querySelectorAll) return false;
const values = valuesFromDetail();
updateReadiness(values);
return saveProgress(values);
}
nodes.detail?.addEventListener?.('input', captureProgress);
nodes.detail?.addEventListener?.('change', captureProgress);
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
setText(nodes.status, 'Human Gates inbox zero.');
return;
}
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
setHtml(nodes.list, queue.items.map(item =>
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
).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>'
).join('');
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
'<h4>History</h4><ul>' + history + '</ul>' +
'<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
'<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' + (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();
}
async function load() {
const epoch = ++loadEpoch;
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
if (accountKey !== loadedAccountKey) {
loadedAccountKey = accountKey;
queue = { pending_count: 0, items: [] };
reviewSnapshot = [];
reviewIndex = -1;
render();
}
const cached = restore();
if (cached) {
queue = cached;
render();
publish({available:true, authoritative:false, cached:true});
}
try {
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
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});
return queue;
} catch (error) {
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!cached) {
publish({available:false, authoritative:false});
throw error;
}
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
return queue;
}
}
function reviewNext() {
if (reviewIndex < 0) {
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
}
const item = reviewSnapshot[reviewIndex] || null;
renderDetail(item);
if (item) {
const index = reviewIndex;
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
reviewSnapshot[index] = detail;
renderDetail(detail);
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
}
return item;
}
function select(gateId) {
if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
const index = reviewSnapshot.findIndex(item => item.id === gateId);
if (index < 0) throw new Error('Gate is not in the current review snapshot.');
reviewIndex = index;
return reviewNext();
}
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;
decisionKeys.set(operation, key);
return { operation, key };
}
function completeDecision(item, decision, operation, receipt) {
decisionKeys.delete(operation);
clearPendingDecision(item);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
}
async function postDecision(item, pending) {
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
body: JSON.stringify(pending.payload),
});
return completeDecision(item, pending.decision, pending.operation, receipt);
}
function decisionError(message, selector) {
const error = new Error(message);
error.targetSelector = selector;
return error;
}
async function decideAndNext(decision, values = {}) {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const checklist = values.checklist || {};
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
if (decision === 'release' && !complete) {
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
}
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
}
if (decision === 'hold' && !String(values.reason || '').trim()) {
throw decisionError('A hold reason is required.', '[data-gate-reason]');
}
const payload = {
expected_revision: item.revision, decision,
reason: String(values.reason || '').trim(),
override_reason: String(values.override_reason || '').trim(), checklist,
};
if (decisionFlight) return decisionFlight;
const operation = (async () => {
const decisionKey = idempotencyKey(item, decision, payload);
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 {
return await operation;
} finally {
if (decisionFlight === operation) decisionFlight = null;
}
}
async function recoverDecision() {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const pending = restorePendingDecision(item);
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
if (decisionFlight) return decisionFlight;
const operation = postDecision(item, pending);
decisionFlight = operation;
try { return await operation; }
finally { if (decisionFlight === operation) decisionFlight = null; }
}
async function submitDecision(decision) {
const values = valuesFromDetail();
updateReadiness(values);
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
buttons.forEach(button => { button.disabled = true; });
try {
return await decideAndNext(decision, values);
} catch (error) {
if (errorNode) {
errorNode.textContent = error?.message || 'The decision could not be saved.';
errorNode.hidden = false;
}
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
.find(input => input.dataset.gateChecklist === key);
}
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
target?.focus?.({preventScroll:true});
throw error;
} finally {
buttons.forEach(button => { button.disabled = false; });
}
}
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
setView('pending');
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
return reviewNext();
})();
openFlight = operation;
const clearFlight = () => {
if (openFlight === operation) openFlight = null;
};
operation.then(clearFlight, clearFlight);
return operation;
}
return {
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)),
route: () => location.hash,
};
}
if (typeof module !== 'undefined') module.exports = createHumanGates;
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;

View File

@ -187,20 +187,7 @@
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
<button class="human-gates-launcher" id="open-human-gates" type="button">Review next <span id="human-gates-count">0</span></button>
</div>
<section id="human-gates" class="human-gates" aria-labelledby="human-gates-heading" hidden>
<div class="human-gates-header">
<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>
<details class="work-settings">
<summary id="work-settings-toggle">Queue &amp; settings · <span id="active-work-queue">All</span></summary>
<div class="work-settings-panel">
@ -237,8 +224,6 @@
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-human-gates"><input id="push-human-gates" type="checkbox" /> Notify me when release decisions are waiting</label>
<span class="small" id="push-human-gates-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
<div class="push-quiet-hours-times">
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
@ -390,19 +375,6 @@
</div>
</div>
</section>
<section class="progressive-work-detail" id="progressive-work-detail" role="dialog" aria-modal="true" aria-labelledby="progressive-work-detail-title" hidden>
<div class="progressive-work-detail-panel">
<header>
<div><p class="small muted">My Work</p><h2 id="progressive-work-detail-title">Work item</h2></div>
<button id="close-progressive-work-detail" type="button">Close</button>
</header>
<p class="small" id="progressive-work-detail-meta"></p>
<p id="progressive-work-detail-reason"></p>
<p class="muted">The full workspace is still loading. You can read this assignment now or open it in Gitea.</p>
<a class="button-link" id="open-progressive-work-gitea" href="" hidden>Open in Gitea</a>
</div>
</section>
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
<section class="release-receipt-panel">
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release watchlist</h2></div><button id="close-release-receipt" type="button">Close</button></header>
@ -1053,7 +1025,6 @@
<div class="small" id="issue-sheet-key"></div>
<h3 id="issue-sheet-title">Assigned issue</h3>
</div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button id="close-issue-sheet" type="button">Close sheet</button>
</div>
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
@ -1554,7 +1525,6 @@
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<nav class="mobile-update-detail-nav" aria-label="Update sections">
@ -1678,7 +1648,6 @@
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button id="close-pull-sheet" type="button">Close</button>
</div>
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
@ -1915,7 +1884,6 @@
<div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3>
</div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button class="review-action" id="close-review-sheet">Close</button>
</div>
<nav class="mobile-review-detail-nav" aria-label="Review sections">
@ -2181,65 +2149,25 @@
<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">Start / Continue</p>
<button id="mobile-queue-next-action" type="button">Find Work</button>
</section>
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
<button class="mobile-pinned-work-toggle" id="mobile-pinned-work-toggle" type="button" aria-controls="mobile-pinned-work-list" aria-expanded="false" hidden>Show all</button>
</section>
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
<h3 id="mobile-recent-work-heading">Recent work</h3>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section>
<details class="mobile-queue-priority" id="mobile-queue-priority">
<summary>Customize routine order</summary>
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
<div id="mobile-queue-priority-list" class="mobile-queue-priority-list"></div>
<div class="mobile-queue-priority-footer">
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
</div>
<div id="mobile-queue-priority-conflict" class="mobile-queue-priority-conflict" hidden>
<p class="small">This routine was changed on another device. Choose which complete order to keep.</p>
<button id="keep-local-mobile-queue-priority" type="button">Keep this device</button>
<button id="use-remote-mobile-queue-priority" type="button">Use other device</button>
</div>
</details>
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
<h3 id="mobile-queue-active-heading">Active now</h3>
<div class="mobile-queue-list" id="mobile-queue-active-list"></div>
</section>
<section class="mobile-queue-group" aria-labelledby="mobile-queue-planning-heading">
<h3 id="mobile-queue-planning-heading">Plan &amp; organize</h3>
<div class="mobile-queue-list" id="mobile-queue-planning-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
</div>
</section>
<details class="mobile-queue-all">
<summary>All queues</summary>
<div class="mobile-queue-list" id="mobile-queue-all-list">
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="gate" type="button"><span><strong>Human Gates</strong><small>Release decisions</small></span><span data-mobile-queue-count="gate">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
</details>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
</section>
</dialog>
@ -2356,9 +2284,7 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/progressive-live-snapshot.js"></script>
<script src="static/progressive-my-work.js"></script>
<script src="static/progressive-mobile-dock.js"></script>
<script src="static/progressive-capture.js"></script>
<script src="static/agenda-replan.js"></script>
<script src="static/agenda-calendar.js"></script>
@ -2430,8 +2356,6 @@
<script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-first-task.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-recent-work.js"></script>
<script src="static/mobile-queue-priority.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
<script src="static/mobile-start-day.js"></script>
@ -2464,8 +2388,6 @@
<script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script>
<script src="static/human-gates.js"></script>
<script src="static/progressive-human-gates.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>

View File

@ -11,7 +11,7 @@
}) {
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
let enabled = false;
const confirmedCounts = {updates:0, following:0, 'human-gates':0};
const confirmedCounts = {updates:0, following:0};
let renderedCount = null;
@ -39,9 +39,7 @@
}
async function render() {
const confirmedCount = Math.min(
9999, confirmedCounts.updates + confirmedCounts.following + confirmedCounts['human-gates']
);
const confirmedCount = Math.min(9999, confirmedCounts.updates + confirmedCounts.following);
if (!enabled || !available() || renderedCount === confirmedCount) return true;
try {
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
@ -63,12 +61,10 @@
if (enabled) {
if (confirmedCounts.updates > 0) await syncCount('updates', confirmedCounts.updates);
if (confirmedCounts.following > 0) await syncCount('following', confirmedCounts.following);
if (confirmedCounts['human-gates'] > 0) await syncCount('human-gates', confirmedCounts['human-gates']);
}
if (!enabled && available()) {
confirmedCounts.updates = 0;
confirmedCounts.following = 0;
confirmedCounts['human-gates'] = 0;
try {
await navigator.clearAppBadge();
renderedCount = null;

View File

@ -8,110 +8,28 @@
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
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 continuation = [
['delivery', 'Recover Delivery'],
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['filed', 'Review Filed'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
];
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 online = options.isOnline ? options.isOnline() : true;
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
);
const match = continuation.find(([name]) => Number(counts[name]) > 0);
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 names = activeQueueNames();
const active = names
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
.filter(item => item.count > 0);
names.forEach(name => {
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
active.push({name, unavailable: true});
}
});
return {
nextUp: adaptiveRecommendation(),
active,
planning: ['today', 'tomorrow', 'week'],
all: allQueues.slice(),
};
}
function renderPresentation() {
const view = presentation();
const planning = new Set(view.planning);
const active = view.active.filter(item => !planning.has(item.name));
const activeNames = new Set(active.map(item => item.name));
if (options.nextAction) {
options.nextAction.textContent = view.nextUp.label;
options.nextAction.dataset.queue = view.nextUp.name;
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];
if (!row) return;
if (item.unavailable) row.setAttribute('data-unavailable', 'true');
else row.removeAttribute('data-unavailable');
options.activeList?.append(row);
});
view.planning.forEach(name => {
const row = options.rows?.[name];
if (row) options.planningList?.append(row);
});
view.all.forEach(name => {
if (planning.has(name) || activeNames.has(name)) return;
const row = options.rows?.[name];
if (!row) return;
row.removeAttribute('data-unavailable');
options.allList?.append(row);
});
if (options.activeSection) options.activeSection.hidden = active.length === 0;
return view;
}
function open(name) {
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
if (name === 'gate' && options.openHumanGates) return options.openHumanGates();
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
@ -128,11 +46,7 @@
}
function continueWork() {
const next = adaptiveRecommendation();
if (next.name === 'prepare') {
options.openPreparation();
return 'prepare';
}
const next = recommend();
if (next.name === 'find') {
options.openFindWork();
return 'find';
@ -140,5 +54,5 @@
return open(next.name);
}
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
return { open, recommend, continueWork };
});

View File

@ -1,340 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueuePriority = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueuePriority(options = {}) {
const DEFAULT_ORDER = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const storage = options.storage;
const getLogin = options.getLogin || (() => '');
const fetchJson = options.fetchJson;
const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
let memory = null;
const syncFlights = new Map();
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempts = 0;
function key() {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function valid(order) {
return Array.isArray(order) && order.length === DEFAULT_ORDER.length &&
new Set(order).size === DEFAULT_ORDER.length &&
order.every(name => DEFAULT_ORDER.includes(name) && typeof name === 'string');
}
function fresh() {
return {revision:0, order:DEFAULT_ORDER.slice(), pending:false, status:'ready', remote:null};
}
function read() {
const accountKey = key();
if (!accountKey || !storage) return fresh();
if (memory?.key === accountKey) return memory.value;
let value = fresh();
try {
const saved = JSON.parse(storage.getItem(accountKey) || 'null');
if (valid(saved)) value = {revision:0, order:saved.slice(), pending:true, status:'pending', remote:null};
else if (saved && valid(saved.order) && Number.isInteger(saved.revision) && saved.revision >= 0) {
value = {
revision:saved.revision, order:saved.order.slice(), pending:Boolean(saved.pending),
status:saved.status === 'conflict' ? 'conflict' : (saved.pending ? 'pending' : 'ready'),
remote:saved.remote && valid(saved.remote.order) ? {
revision:Number(saved.remote.revision) || 0, order:saved.remote.order.slice(),
} : null,
};
}
} catch (_error) {}
memory = {key:accountKey, value};
return value;
}
function persist(value) {
const accountKey = key();
if (!accountKey || !storage) return false;
memory = {key:accountKey, value};
try {
storage.setItem(accountKey, JSON.stringify(value));
return true;
} catch (_error) {
return false;
}
}
function snapshot() {
const value = read();
return {
revision:value.revision, order:value.order.slice(), pending:value.pending,
status:value.status, remote:value.remote ? {revision:value.remote.revision, order:value.remote.order.slice()} : null,
};
}
function announce(value) {
if (options.status) {
options.status.textContent = ({pending:'Sync pending.', conflict:'Routine order changed on another device.',
syncing:'Syncing routine order…', error:'Routine order could not sync.', ready:''})[value.status] || '';
}
options.onState?.(snapshot());
}
function getOrder() {
return read().order.slice();
}
function save(order) {
if (!key() || !valid(order)) return false;
const current = read();
const value = {revision:current.revision, order:order.slice(), pending:true, status:'pending', remote:null};
if (!persist(value)) return false;
options.onChange?.(order.slice());
announce(value);
scheduleSync();
return true;
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function clearRetry() {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
retryAttempts = 0;
}
function transient(error) {
const status = Number(error?.status) || 0;
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
}
function scheduleRetry(accountKey) {
if (retryTimer && retryAccount !== accountKey) clearRetry();
if (retryTimer || key() !== accountKey) return false;
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
retryAttempts += 1;
retryAccount = accountKey;
retryTimer = setTimer(() => {
retryTimer = null;
retryAccount = '';
const current = read();
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
void sync();
}, delay);
return true;
}
function move(name, delta) {
const order = getOrder();
const index = order.indexOf(name);
const next = index + (Number(delta) < 0 ? -1 : 1);
if (index < 0 || next < 0 || next >= order.length) return order;
[order[index], order[next]] = [order[next], order[index]];
save(order);
return order.slice();
}
function reset() {
const order = DEFAULT_ORDER.slice();
if (fetchJson && key()) save(order);
else {
const accountKey = key();
if (accountKey && storage) {
try { storage.removeItem(accountKey); } catch (_error) {}
}
memory = null;
options.onChange?.(order.slice());
}
return order;
}
function adopt(snapshotValue, accountKey = key()) {
if (!accountKey || key() !== accountKey) return snapshot();
if (!snapshotValue || !Number.isInteger(snapshotValue.revision) || snapshotValue.revision < 0 || !valid(snapshotValue.order)) {
throw new Error('Queue priority response is invalid.');
}
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
clearRetry();
persist(value);
options.onChange?.(value.order.slice());
announce(value);
render();
return snapshot();
}
async function load() {
const accountKey = key();
if (!fetchJson || !accountKey) return snapshot();
try {
const remote = await fetchJson('api/v1/queue-priority');
if (key() !== accountKey) return snapshot();
const local = read();
if (local.pending) {
local.remote = valid(remote?.order) ? {revision:remote.revision, order:remote.order.slice()} : null;
persist(local);
return sync();
}
return adopt(remote, accountKey);
} catch (_error) {
if (key() !== accountKey) return snapshot();
const current = read();
current.status = current.pending ? 'pending' : 'error';
persist(current); announce(current);
return snapshot();
}
}
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
if (!current.pending) return snapshot();
const sent = {revision:current.revision, order:current.order.slice()};
current.status = 'syncing'; persist(current); announce(current);
try {
const saved = await fetchJson('api/v1/queue-priority', {
method:'PUT', headers:{'Content-Type':'application/json'},
body:JSON.stringify(sent),
});
if (key() !== accountKey) return snapshot();
if (!saved || !Number.isInteger(saved.revision) || !valid(saved.order)) {
throw new Error('Queue priority response is invalid.');
}
const latest = read();
if (latest.order.some((name, index) => name !== sent.order[index])) {
latest.revision = saved.revision; latest.pending = true;
latest.status = 'pending'; latest.remote = null;
persist(latest); announce(latest);
continue;
}
return adopt(saved, accountKey);
} catch (error) {
if (key() !== accountKey) return snapshot();
const latest = read();
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
clearRetry();
latest.status = 'conflict'; latest.pending = true;
latest.remote = {revision:remote.revision, order:remote.order.slice()};
} else {
latest.status = 'pending'; latest.pending = true;
if (transient(error)) scheduleRetry(accountKey);
}
persist(latest); announce(latest); render();
return snapshot();
}
}
return snapshot();
}
function sync() {
if (debounceTimer) {
clearTimer(debounceTimer);
debounceTimer = null;
}
const accountKey = key();
const current = read();
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
if (syncFlights.has(accountKey)) return syncFlights.get(accountKey);
const flight = drain(accountKey).finally(() => {
if (syncFlights.get(accountKey) === flight) syncFlights.delete(accountKey);
});
syncFlights.set(accountKey, flight);
return flight;
}
async function useLocal() {
const current = read();
if (!current.remote) return snapshot();
current.revision = current.remote.revision;
current.remote = null; current.pending = true; current.status = 'pending';
persist(current);
return sync();
}
function useRemote() {
const current = read();
return current.remote ? adopt(current.remote) : snapshot();
}
function displayName(name) {
return labels[name] || name.charAt(0).toUpperCase() + name.slice(1);
}
function render() {
if (!options.list || !documentRef) return getOrder();
const order = getOrder();
const signedIn = Boolean(key());
const rows = order.map((name, index) => {
const row = documentRef.createElement('div');
row.setAttribute('data-queue-priority', name);
row.setAttribute('class', 'mobile-queue-priority-row');
const label = documentRef.createElement('span');
label.textContent = displayName(name);
const controls = documentRef.createElement('span');
controls.setAttribute('class', 'mobile-queue-priority-controls');
const earlier = documentRef.createElement('button');
earlier.textContent = 'Earlier'; earlier.setAttribute('type', 'button');
earlier.setAttribute('aria-label', 'Move ' + displayName(name) + ' earlier');
earlier.disabled = !signedIn || index === 0;
earlier.addEventListener('click', () => {
move(name, -1); render();
});
const later = documentRef.createElement('button');
later.textContent = 'Later'; later.setAttribute('type', 'button');
later.setAttribute('aria-label', 'Move ' + displayName(name) + ' later');
later.disabled = !signedIn || index === order.length - 1;
later.addEventListener('click', () => {
move(name, 1); render();
});
controls.append(earlier, later); row.append(label, controls);
return row;
});
options.list.replaceChildren(...rows);
if (options.resetButton) options.resetButton.disabled = !signedIn;
if (options.conflict) options.conflict.hidden = read().status !== 'conflict';
return order;
}
function start() {
options.resetButton?.addEventListener('click', () => {
reset(); render();
});
options.keepLocalButton?.addEventListener('click', () => { void useLocal(); });
options.useRemoteButton?.addEventListener('click', () => { useRemote(); });
return render();
}
function startLifecycle(lifecycle = {}) {
const windowObject = lifecycle.window;
const lifecycleDocument = lifecycle.document;
const reconcile = () => {
if (!key()) return Promise.resolve(snapshot());
return read().pending ? sync() : load();
};
windowObject?.addEventListener?.('online', () => { void reconcile(); });
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
if (!lifecycleDocument.hidden) void reconcile();
});
return reconcile;
}
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});

View File

@ -1,455 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileRecentWork = factory;
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
'use strict';
const storage = options.storage;
const getLogin = options.getLogin;
const fetchJson = options.fetchJson;
const limit = Math.max(1, Number(options.limit) || 5);
const pinnedLimit = Math.max(1, Number(options.pinnedLimit) || 20);
const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryMs = Number.isFinite(options.retryMs) ? Math.max(1, options.retryMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryMs, options.retryMaxMs) : 30000;
let syncFlight = null;
let syncAccount = '';
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempt = 0;
let operationSequence = 0;
let pinsExpanded = false;
let currentItem = null;
const detailPins = Array.from(options.detailPins || []);
detailPins.forEach(button => button.addEventListener?.('click', () => {
if (!currentItem) return;
const isPinned = pinned().some(item => item.route === currentItem.route);
if (isPinned) unpin(currentItem.route);
else pin(currentItem);
}));
options.pinnedToggle?.addEventListener?.('click', () => {
pinsExpanded = !pinsExpanded;
render();
});
function operationId() {
operationSequence += 1;
return Date.now().toString(36) + '-' + operationSequence.toString(36);
}
function clearRetry(resetAttempt = false) {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
if (resetAttempt) retryAttempt = 0;
}
function scheduleRetry(accountKey) {
if (retryTimer || key() !== accountKey || !hasPending(read())) return false;
retryAccount = accountKey;
const delay = Math.min(retryMaxMs, retryMs * (2 ** retryAttempt));
retryAttempt += 1;
retryTimer = setTimer(() => {
const timer = retryTimer;
retryTimer = null;
retryAccount = '';
if (timer) clearTimer(timer);
if (key() === accountKey && hasPending(read())) void sync();
}, delay);
return true;
}
function login() {
return String(getLogin?.() || '').trim().toLowerCase();
}
function key() {
const owner = login();
return owner ? prefix + owner : '';
}
function normalize(item) {
const kind = String(item?.kind || '');
const number = Number(item?.number ?? item?.notification_id);
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
let route = '';
let repository = '';
if (kind === 'update') {
route = '#/my-work/update/' + number;
} else {
repository = String(item?.repository || '');
if (!repositoryPattern.test(repository)) return null;
route = '#/my-work/' + kind + '/' + repository + '/' + number;
}
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
if (!title) return null;
return {kind, ...(repository ? {repository} : {}), number, title, route};
}
function normalizeList(value, maximum = limit) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (item && !unique.some(existing => existing.route === item.route)) unique.push(item);
if (unique.length === maximum) break;
}
return unique;
}
function normalizePinOps(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const action = candidate?.action;
const item = action === 'pin' ? normalize(candidate.item) : null;
const route = action === 'pin' ? item?.route : String(candidate?.route || '');
if ((action !== 'pin' && action !== 'unpin') || !route || (action === 'pin' && !item)) continue;
if (!unique.some(existing => (existing.item?.route || existing.route) === route)) {
const normalized = action === 'pin' ? {action, item} : {action, route};
if (typeof candidate.operationId === 'string' && candidate.operationId) normalized.operationId = candidate.operationId;
unique.push(normalized);
}
if (unique.length === pinnedLimit) break;
}
return unique;
}
function normalizePending(value) {
if (!Array.isArray(value)) return [];
const unique = [];
for (const candidate of value) {
const item = normalize(candidate);
if (!item || unique.some(existing => existing.route === item.route)) continue;
if (typeof candidate.operationId === 'string' && candidate.operationId) item.operationId = candidate.operationId;
unique.push(item);
if (unique.length === limit) break;
}
return unique;
}
function empty() {
return {items:[], pinned:[], pending:[], pinOps:[]};
}
function read() {
const storageKey = key();
if (!storageKey) return empty();
try {
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
if (Array.isArray(parsed)) return {...empty(), items:normalizeList(parsed)};
return {
items:normalizeList(parsed?.items),
pinned:normalizeList(parsed?.pinned, pinnedLimit),
pending:normalizePending(parsed?.pending),
pinOps:normalizePinOps(parsed?.pinOps),
};
} catch (_) {
return empty();
}
}
function persist(value, accountKey = key()) {
if (!accountKey || accountKey !== key()) return false;
try {
storage.setItem(accountKey, JSON.stringify({
items:normalizeList(value.items),
pinned:normalizeList(value.pinned, pinnedLimit),
pending:normalizePending(value.pending),
pinOps:normalizePinOps(value.pinOps),
}));
return true;
} catch (_) {
return false;
}
}
function hasPending(value) {
return value.pending.length > 0 || value.pinOps.length > 0;
}
function announce(value = read(), status = null) {
if (!options.status) return;
options.status.textContent = status || (hasPending(value) ? 'Sync pending.' : '');
}
function items() {
return read().items;
}
function pinned() {
return read().pinned;
}
function state() {
const value = read();
const pendingCount = value.pending.length + value.pinOps.length;
return {pending:pendingCount > 0, pendingCount};
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function record(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.items = [normalized, ...current.items.filter(existing => existing.route !== normalized.route)].slice(0, limit);
current.pinned = current.pinned.some(existing => existing.route === normalized.route)
? [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)]
: current.pinned;
current.pending = [{...normalized, operationId:operationId()}, ...current.pending.filter(existing => existing.route !== normalized.route)].slice(0, limit);
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function queuePinOp(current, operation) {
const route = operation.item?.route || operation.route;
current.pinOps = [{...operation, operationId:operationId()}, ...current.pinOps.filter(existing => (existing.item?.route || existing.route) !== route)];
}
function pin(item) {
const accountKey = key();
const normalized = normalize(item);
if (!accountKey || !normalized) return false;
const current = read();
current.pinned = [normalized, ...current.pinned.filter(existing => existing.route !== normalized.route)].slice(0, pinnedLimit);
queuePinOp(current, {action:'pin', item:normalized});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function unpin(route) {
const accountKey = key();
route = String(route || '');
if (!accountKey || !route) return false;
const current = read();
if (!current.pinned.some(item => item.route === route)) return false;
current.pinned = current.pinned.filter(item => item.route !== route);
queuePinOp(current, {action:'unpin', route});
if (!persist(current, accountKey)) return false;
announce(current);
render();
scheduleSync();
return true;
}
function applyPinOps(remote, operations) {
let result = normalizeList(remote, pinnedLimit);
for (const operation of [...normalizePinOps(operations)].reverse()) {
const route = operation.item?.route || operation.route;
result = operation.action === 'pin'
? [operation.item, ...result.filter(item => item.route !== route)].slice(0, pinnedLimit)
: result.filter(item => item.route !== route);
}
return result;
}
function adopt(snapshot, accountKey, pending = [], pinOps = []) {
if (key() !== accountKey || !snapshot || !Array.isArray(snapshot.items)) return false;
const remote = normalizeList(snapshot.items);
const remotePinned = normalizeList(snapshot.pinned, pinnedLimit);
const unsent = normalizePending(pending);
const unsentPinOps = normalizePinOps(pinOps);
const value = {
items:normalizeList([...unsent, ...remote]),
pinned:applyPinOps(remotePinned, unsentPinOps),
pending:unsent,
pinOps:unsentPinOps,
};
persist(value, accountKey);
announce(value);
render();
return true;
}
async function drain(accountKey) {
while (key() === accountKey) {
const current = read();
if (!hasPending(current)) return current;
const sending = current.pending[current.pending.length - 1];
const pinOperation = sending ? null : current.pinOps[current.pinOps.length - 1];
announce(current, 'Syncing recent work…');
try {
let snapshot;
if (sending) {
snapshot = await fetchJson('api/v1/recent-work', {
method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(normalize(sending)),
});
} else {
const isPin = pinOperation.action === 'pin';
snapshot = await fetchJson('api/v1/recent-work/pin', {
method:isPin ? 'PUT' : 'DELETE',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(isPin ? pinOperation.item : {route:pinOperation.route}),
});
}
if (key() !== accountKey) return read();
const latest = read();
const pending = sending
? latest.pending.filter(item => item.operationId !== sending.operationId)
: latest.pending;
const pinOps = pinOperation
? latest.pinOps.filter(operation => {
const sameRoute = (operation.item?.route || operation.route) === (pinOperation.item?.route || pinOperation.route);
return !sameRoute || operation.action !== pinOperation.action || operation.operationId !== pinOperation.operationId;
})
: latest.pinOps;
if (!adopt(snapshot, accountKey, pending, pinOps)) throw new Error('Recent work response is invalid.');
retryAttempt = 0;
} catch (_error) {
if (key() === accountKey) {
announce(read());
scheduleRetry(accountKey);
}
return read();
}
}
return read();
}
function sync() {
if (debounceTimer) { clearTimer(debounceTimer); debounceTimer = null; }
const accountKey = key();
if (retryTimer && retryAccount !== accountKey) clearRetry(true);
else if (retryTimer) clearRetry(false);
if (!fetchJson || !accountKey || !hasPending(read())) return Promise.resolve(read());
if (syncFlight && syncAccount === accountKey) return syncFlight;
syncAccount = accountKey;
syncFlight = drain(accountKey).finally(() => {
if (syncAccount === accountKey) { syncFlight = null; syncAccount = ''; }
});
return syncFlight;
}
async function load() {
const accountKey = key();
if (!fetchJson || !accountKey) return read();
try {
const snapshot = await fetchJson('api/v1/recent-work');
if (key() !== accountKey) return read();
const current = read();
adopt(snapshot, accountKey, current.pending, current.pinOps);
return hasPending(current) ? sync() : read();
} catch (_error) {
if (key() === accountKey) announce(read(), hasPending(read()) ? null : 'Recent work could not sync.');
return read();
}
}
function startLifecycle(lifecycle = {}) {
const reconcile = () => hasPending(read()) ? sync() : load();
lifecycle.window?.addEventListener?.('online', () => { void reconcile(); });
lifecycle.document?.addEventListener?.('visibilitychange', () => {
if (!lifecycle.document.hidden) void reconcile();
});
return reconcile;
}
function detail(item) {
return item.kind === 'update'
? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
}
function row(item, isPinned) {
const itemDetail = detail(item);
const wrapper = options.document.createElement('div');
const button = options.document.createElement('button');
const action = options.document.createElement('button');
const copy = options.document.createElement('span');
const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small');
wrapper.setAttribute('class', 'mobile-recent-work-row');
primary.textContent = item.title;
secondary.textContent = itemDetail;
copy.appendChild(primary);
copy.appendChild(secondary);
button.appendChild(copy);
button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + itemDetail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => {
if (isPinned) record(item);
options.openRoute?.(item.route);
});
action.textContent = isPinned ? 'Unpin' : 'Pin';
action.setAttribute('type', 'button');
action.setAttribute('data-recent-work-pin', isPinned ? 'unpin' : 'pin');
action.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + item.title);
action.addEventListener('click', () => isPinned ? unpin(item.route) : pin(item));
wrapper.appendChild(button);
wrapper.appendChild(action);
return wrapper;
}
function renderCurrent() {
const isPinned = currentItem && pinned().some(item => item.route === currentItem.route);
detailPins.forEach(button => {
button.hidden = !currentItem;
if (!currentItem) return;
button.textContent = isPinned ? 'Pinned' : 'Pin';
button.setAttribute('aria-pressed', isPinned ? 'true' : 'false');
button.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + currentItem.title);
button.setAttribute('data-current-work-pin', isPinned ? 'unpin' : 'pin');
});
}
function setCurrent(item) {
currentItem = normalize(item);
renderCurrent();
return Boolean(currentItem);
}
function render() {
const recent = items();
const fixed = pinned();
const fixedRoutes = new Set(fixed.map(item => item.route));
const visibleRecent = recent.filter(item => !fixedRoutes.has(item.route));
const list = options.list;
const section = options.section;
if (list && section && options.document) {
const rows = visibleRecent.map(item => row(item, false));
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
}
if (options.pinnedList && options.pinnedSection && options.document) {
const visiblePins = pinsExpanded ? fixed : fixed.slice(0, 3);
const rows = visiblePins.map(item => row(item, true));
options.pinnedList.replaceChildren(...rows);
options.pinnedSection.hidden = rows.length === 0;
}
if (options.pinnedToggle) {
options.pinnedToggle.hidden = fixed.length <= 3;
options.pinnedToggle.textContent = pinsExpanded ? 'Show fewer' : 'Show all ' + fixed.length;
options.pinnedToggle.setAttribute('aria-expanded', pinsExpanded ? 'true' : 'false');
options.pinnedToggle.setAttribute('aria-controls', 'mobile-pinned-work-list');
}
renderCurrent();
return visibleRecent.length + fixed.length;
}
return {items, pinned, record, pin, unpin, setCurrent, render, load, sync, startLifecycle, state};
});

View File

@ -6,7 +6,6 @@
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
const reviewOrder = [
['delivery', 'Delivery recovery'],
['gate', 'Human Gates'],
['agenda', 'Agenda'],
['attention', 'Attention'],
['update', 'Updates'],
@ -105,12 +104,6 @@
function briefing() {
const counts = options.getCounts ? options.getCounts() : {};
let phases = reviewPhases(counts);
const gateUnavailable = counts.gateUnavailable === true;
if (gateUnavailable) {
phases = phases.filter(phase => phase.name !== 'gate');
const afterDelivery = phases[0]?.name === 'delivery' ? 1 : 0;
phases.splice(afterDelivery, 0, {name:'gate', label:'Human Gates unavailable · retry', count:count(counts.gate)});
}
const followingUnavailable = counts.followingUnavailable === true;
if (followingUnavailable) {
phases = phases.filter(phase => phase.name !== 'following');
@ -122,17 +115,14 @@
const other = total - delivery;
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
const nextLabel = next === 'delivery' ? 'Review Delivery' :
(next === 'gate' && gateUnavailable ? 'Retry Human Gates' :
(next === 'following' && followingUnavailable ? 'Retry Following' :
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'))));
const gateRetry = next === 'gate' && gateUnavailable;
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work')));
const followingRetry = next === 'following' && followingUnavailable;
return {
total,
next,
label: nextLabel,
summary: gateRetry ? 'Human Gates need retry before Today · ' + today + ' planned' :
followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
summary: followingRetry ? 'Following needs retry before Today · ' + today + ' planned' :
delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
' · ' + today + ' planned' :
@ -185,16 +175,14 @@
options.elements.phases.textContent = current.phases.length ?
current.phases.map(phase => phase.label + ' ' + phase.count).join(' · ') :
'All urgent queues reviewed';
if (options.elements.action) {
options.elements.action.textContent = checkpoint() ? 'Resume preparation · ' + current.label : current.label;
}
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?.action) options.elements.action.addEventListener('click', startNext);
if (options.elements) options.elements.action.addEventListener('click', startNext);
}
return {briefing, completePhase, finish, reconcile, render, start, startNext, state};

View File

@ -70,10 +70,9 @@
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',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', 'Filed', 'Later', 'Drafts'].includes(text);
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
mode === 'prepare-resume' ? 'Resume preparation' :
@ -98,9 +97,9 @@
}
function updateQueues(counts) {
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
const names = 'today agenda delivery attention update filed 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', 'following', 'filed', 'authored', 'later', 'draft'];
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', '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);
@ -108,9 +107,6 @@
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;
@ -128,12 +124,9 @@
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', 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');

View File

@ -1,110 +0,0 @@
function createProgressiveHumanGates(options = {}) {
const document = options.document || globalThis.document;
const location = options.location || globalThis.location;
const history = options.history || globalThis.history;
const storage = options.storage || globalThis.localStorage;
const isOnline = options.isOnline || (() => globalThis.navigator?.onLine !== false);
const fetchJson = options.fetchJson || (async (path, requestOptions = {}) => {
const response = await fetch(path, requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
return payload;
});
const liveSnapshot = options.liveSnapshot || null;
const getIdentity = options.getIdentity || (async () => {
const response = liveSnapshot ? await liveSnapshot.acquire({requireIdentity:true}) :
await fetchJson('api/v1/live', {headers:{Accept:'application/json'}});
const user = response?.context?.user || {};
const login = String(user.login || '').trim();
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
});
const query = selector => document.querySelector(selector);
const nodes = {
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 = '';
let started = false;
let startFlight = null;
const controller = createHumanGates({
storage, isOnline, location, fetchJson,
getLogin:() => login, getAccountKey:() => accountKey, nodes,
});
const showError = error => {
if (nodes.status) nodes.status.textContent = error?.message || 'Human Gates are unavailable.';
};
const open = () => start(true).catch(showError);
query('#open-human-gates')?.addEventListener?.('click', open);
query('#close-human-gates')?.addEventListener?.('click', () => {
if (nodes.panel) nodes.panel.hidden = true;
if (location.hash === '#/my-work/human-gates') history.replaceState({}, '', '#/my-work');
});
nodes.list?.addEventListener?.('click', event => {
const card = event.target?.closest?.('[data-human-gate-id]');
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;
if (started) return controller.open().then(() => true);
if (startFlight) return startFlight;
startFlight = (async () => {
const identity = await getIdentity();
login = String(identity?.login || '').trim();
accountKey = String(identity?.accountKey || login).trim();
if (!login) throw new Error('Authenticated account identity is required.');
await controller.open();
started = true;
return true;
})();
try { return await startFlight; }
finally { startFlight = null; }
}
return {
start,
handoff() {
return {
controller, started,
adoptIdentity(nextLogin, nextAccountKey) {
login = String(nextLogin || '').trim();
accountKey = String(nextAccountKey || login).trim();
},
};
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveHumanGates = createProgressiveHumanGates({
document, liveSnapshot:window.stackchainProgressiveLiveSnapshot,
});
void window.stackchainProgressiveHumanGates.start().catch(() => {});
}
if (typeof module !== 'undefined' && module.exports) {
globalThis.createHumanGates = globalThis.createHumanGates || require('./human-gates.js');
module.exports = createProgressiveHumanGates;
}

View File

@ -1,49 +0,0 @@
function createProgressiveLiveSnapshot({fetchSnapshot}) {
let value = null;
let flight = null;
const hasIdentity = snapshot => {
const user = snapshot?.context?.user || {};
return Boolean(String(user.login || '').trim());
};
const requireUsable = (snapshot, options) => {
if (!options?.requireIdentity || hasIdentity(snapshot)) return snapshot;
if (value === snapshot) value = null;
throw new Error('Authenticated account identity is unavailable.');
};
const acquire = (options = {}) => {
if (value) return Promise.resolve().then(() => requireUsable(value, options));
if (flight) return flight.then(snapshot => requireUsable(snapshot, options));
flight = Promise.resolve().then(() => fetchSnapshot()).then(snapshot => {
value = snapshot;
flight = null;
return snapshot;
}, error => {
flight = null;
throw error;
});
return flight.then(snapshot => requireUsable(snapshot, options));
};
return {
acquire,
snapshot:() => value,
pending:() => flight,
identity() {
const user = value?.context?.user || {};
const login = String(user.login || '').trim();
return {login, accountKey:login && user.id ? String(user.id) + ':' + login : login};
},
};
}
if (typeof window !== 'undefined') {
window.stackchainProgressiveLiveSnapshot = createProgressiveLiveSnapshot({
fetchSnapshot:async () => {
const response = await fetch('api/v1/live', {headers:{Accept:'application/json'}});
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
},
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveLiveSnapshot;

View File

@ -1,119 +0,0 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createProgressiveMobileDock = factory;
})(typeof self !== 'undefined' ? self : this, function createProgressiveMobileDock(options) {
const document = options.document;
const myWork = options.myWork;
const work = document.querySelector('[data-mobile-task="work"]');
const queues = document.querySelector('[data-mobile-task="queues"]');
const sheet = document.querySelector('#mobile-queue-sheet');
const close = document.querySelector('#close-mobile-queues');
const next = document.querySelector('#mobile-queue-next-action');
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
const countElements = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue-count]'))
.map(element => [element.dataset.mobileQueueCount, element]));
const originalDescriptions = Object.fromEntries(Object.entries(rows)
.map(([name, row]) => [name, row.querySelector?.('small')?.textContent || '']));
const originalCounts = Object.fromEntries(Object.entries(countElements)
.map(([name, element]) => [name, element.textContent]));
const supported = new Set(['all', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update']);
const listeners = [];
let started = false;
let lastTask = null;
let unsubscribe = null;
function listen(element, name, listener) {
if (!element) return;
element.addEventListener(name, listener);
listeners.push([element, name, listener]);
}
function render() {
const counts = myWork?.counts?.() || {};
Object.entries(rows).forEach(([name, row]) => {
const count = Math.max(0, Number(counts[name]) || 0);
const countElement = countElements[name];
if (supported.has(name)) {
row.removeAttribute('data-unavailable');
row.removeAttribute('data-progressive-loading');
if (countElement) countElement.textContent = String(count);
return;
}
row.setAttribute('data-unavailable', 'true');
row.setAttribute('data-progressive-loading', 'true');
const description = row.querySelector?.('small');
if (description) description.textContent = 'Still loading';
if (countElement) countElement.textContent = '…';
});
const actionable = ['attention', 'filed', 'authored', 'review', 'update']
.find(name => Number(counts[name]) > 0);
if (next) {
if (actionable) {
next.textContent = 'Open ' + (actionable === 'authored' ? 'My PRs' : actionable[0].toUpperCase() + actionable.slice(1))
+ ' (' + counts[actionable] + ')';
next.dataset.queue = actionable;
next.removeAttribute('data-unavailable');
} else {
next.textContent = Number(counts.all) > 0 ? 'Open assigned work (' + counts.all + ')' : 'Assigned work is still loading';
next.dataset.queue = 'all';
if (!Number(counts.all)) next.setAttribute('data-unavailable', 'true');
}
}
}
function openSheet() {
lastTask = 'queues';
render();
if (sheet && !sheet.open) sheet.showModal();
}
function openQueue(name) {
if (!supported.has(name)) return false;
lastTask = 'work';
if (sheet?.open) sheet.close();
myWork?.selectQueue?.(name, {openFirst:true});
return true;
}
function start() {
if (started) return false;
started = true;
listen(work, 'click', () => { lastTask = 'work'; myWork?.openFirst?.(); });
listen(queues, 'click', openSheet);
listen(close, 'click', () => sheet?.open && sheet.close());
Object.entries(rows).forEach(([name, row]) => listen(row, 'click', () => openQueue(name)));
listen(next, 'click', () => openQueue(next.dataset.queue || 'all'));
unsubscribe = myWork?.subscribe?.(render) || null;
render();
return true;
}
function handoff() {
return {queueSheetOpen:Boolean(sheet?.open), lastTask};
}
function stop() {
listeners.splice(0).forEach(([element, name, listener]) => element.removeEventListener(name, listener));
unsubscribe?.();
unsubscribe = null;
Object.entries(rows).forEach(([name, row]) => {
row.removeAttribute('data-unavailable');
row.removeAttribute('data-progressive-loading');
const description = row.querySelector?.('small');
if (description) description.textContent = originalDescriptions[name];
if (countElements[name]) countElements[name].textContent = originalCounts[name];
});
started = false;
}
return {start, stop, handoff, render, openQueue};
});
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMobileDock = createProgressiveMobileDock({
document,
myWork:window.stackchainProgressiveMyWork,
});
window.stackchainProgressiveMobileDock.start();
}

View File

@ -1,19 +1,11 @@
function createProgressiveMyWork({
document, fetchSnapshot, liveSnapshot: snapshotBroker = null,
pollerOptions = {}, lifecycleTarget = globalThis,
document, fetchSnapshot, pollerOptions = {}, lifecycleTarget = globalThis,
}) {
const list = document.querySelector('#my-work-list');
const status = document.querySelector('#my-work-status');
const detail = document.querySelector('#progressive-work-detail');
const detailTitle = document.querySelector('#progressive-work-detail-title');
const detailMeta = document.querySelector('#progressive-work-detail-meta');
const detailReason = document.querySelector('#progressive-work-detail-reason');
const closeDetail = document.querySelector('#close-progressive-work-detail');
const openGitea = document.querySelector('#open-progressive-work-gitea');
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
const listeners = [];
const lifecycleListeners = [];
const subscribers = new Set();
let items = [];
let active = 'all';
let stopped = false;
@ -22,15 +14,9 @@ function createProgressiveMyWork({
let liveSnapshotPromise = null;
let confirmedLogin = '';
let poller = null;
let detailTrigger = null;
let openWork = null;
let contextUnavailable = false;
const deferredQueues = {
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
};
const fetchProgressiveSnapshot = (revisions = {}, options = {}) =>
snapshotBroker && Object.keys(revisions || {}).length === 0 ?
snapshotBroker.acquire() : fetchSnapshot(revisions, options);
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
@ -49,37 +35,6 @@ function createProgressiveMyWork({
filter === 'authored' ? item.is_authored :
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
const notify = () => subscribers.forEach(subscriber => subscriber());
const queueCounts = () => ({
all:items.length,
filed:items.filter(item => matches(item, 'filed')).length,
authored:items.filter(item => matches(item, 'authored')).length,
attention:items.filter(item => matches(item, 'attention')).length,
update:items.filter(item => matches(item, 'update')).length,
review:items.filter(item => matches(item, 'review')).length,
});
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
if (!detail || detail.hidden) return;
detail.hidden = true;
if (restoreFocus) detailTrigger?.focus?.();
detailTrigger = null;
openWork = null;
};
const openProgressiveDetail = (item, trigger) => {
if (!detail || !item) return;
detailTrigger = trigger;
openWork = item;
detailTitle.textContent = item.title || item.key || 'Untitled work';
detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' +
(item.kind === 'pull' ? 'Pull request' : 'Issue');
detailReason.textContent = item.reason || 'Assigned to you';
const href = safeUrl(item.url);
openGitea.hidden = !href;
if (href) openGitea.href = href;
else openGitea.removeAttribute?.('href');
detail.hidden = false;
closeDetail?.focus?.();
};
const updateCounts = () => {
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
@ -90,16 +45,16 @@ function createProgressiveMyWork({
const render = () => {
if (stopped || !list) return;
const visible = visibleItems();
list.innerHTML = contextUnavailable && !items.length ?
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
list.innerHTML = deferredQueues[active] ?
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map(item => {
const href = safeUrl(item.url);
const title = escapeHtml(item.title || item.key || 'Untitled work');
const context = escapeHtml(item.key || '');
const reason = escapeHtml(item.reason || 'Assigned to you');
return '<article class="my-work-card progressive-my-work-card">' +
'<button class="my-work-card-main" type="button" data-progressive-work-index="' + index + '">' +
(href ? '<a class="my-work-card-main" href="' + escapeHtml(href) + '">' : '<div class="my-work-card-main">') +
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
'</button></article>';
(href ? '</a>' : '</div>') + '</article>';
}).join('') : '<div class="muted">No work in this queue.</div>';
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
};
@ -108,46 +63,17 @@ function createProgressiveMyWork({
button.addEventListener('click', listener);
listeners.push([button, listener]);
});
const openListener = event => {
const trigger = event.target?.closest?.('[data-progressive-work-index]');
if (!trigger) return;
const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)];
if (!item) return;
event.preventDefault?.();
openProgressiveDetail(item, trigger);
};
const closeListener = () => closeProgressiveDetail();
const keyListener = event => {
if (event.key !== 'Escape' || detail?.hidden) return;
event.preventDefault?.();
closeProgressiveDetail();
};
list?.addEventListener?.('click', openListener);
closeDetail?.addEventListener?.('click', closeListener);
lifecycleTarget.addEventListener?.('keydown', keyListener);
const applySnapshot = snapshot => {
if (stopped) return false;
const transferable = snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context');
const contextDegraded = transferable && (
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
);
if (contextDegraded) {
contextUnavailable = true;
render();
notify();
if (status) status.textContent = 'Assigned work is reconnecting…';
return false;
}
contextUnavailable = false;
if (transferable) liveSnapshot = snapshot;
const context = snapshot?.context || snapshot || {};
confirmedLogin = String(context.user?.login || '').trim();
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
updateCounts();
render();
notify();
const assigned = items.filter(item => item.is_assigned).length;
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
return true;
@ -156,7 +82,7 @@ function createProgressiveMyWork({
if (typeof createContextPoller === 'function') {
poller = createContextPoller({
...pollerOptions,
fetchContext: fetchProgressiveSnapshot,
fetchContext: fetchSnapshot,
onSnapshot: applySnapshot,
onError: () => {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
@ -174,36 +100,8 @@ function createProgressiveMyWork({
return {
login() { return confirmedLogin; },
counts() { return queueCounts(); },
subscribe(subscriber) {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
openFirst() {
const item = visibleItems()[0];
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
openProgressiveDetail(item, trigger);
return 'opened';
},
selectQueue(name, {openFirst = false} = {}) {
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
if (!allowed.has(name)) return 'unsupported';
active = name;
selectedByUser = true;
render();
notify();
return openFirst ? this.openFirst() : 'selected';
},
handoff() {
const state = { selectedFilter:selectedByUser ? active : null };
if (openWork) {
state.openWork = {
kind:openWork.kind, key:openWork.key, number:openWork.number,
repository:openWork.repository,
};
openWork = null;
}
if (liveSnapshot) {
state.liveSnapshot = liveSnapshot;
liveSnapshot = null;
@ -224,7 +122,7 @@ function createProgressiveMyWork({
));
return Boolean(await request);
}
const request = Promise.resolve().then(() => fetchProgressiveSnapshot());
const request = Promise.resolve().then(() => fetchSnapshot());
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
@ -244,12 +142,8 @@ function createProgressiveMyWork({
stopped = true;
poller?.stop();
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
list?.removeEventListener?.('click', openListener);
closeDetail?.removeEventListener?.('click', closeListener);
lifecycleTarget.removeEventListener?.('keydown', keyListener);
lifecycleListeners.forEach(([eventName, listener]) =>
lifecycleTarget.removeEventListener?.(eventName, listener));
subscribers.clear();
},
};
}
@ -258,7 +152,6 @@ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMyWork = createProgressiveMyWork({
document,
lifecycleTarget: window,
liveSnapshot:window.stackchainProgressiveLiveSnapshot,
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
const query = createContextPoller.buildRevisionQuery(revisions);
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {

View File

@ -5,7 +5,6 @@
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
humanGateControl, humanGateStatus,
quietControl = globalThis.document?.querySelector('#push-quiet-hours'),
quietStart = globalThis.document?.querySelector('#push-quiet-start'),
quietEnd = globalThis.document?.querySelector('#push-quiet-end'),
@ -136,17 +135,14 @@
if (deadlineControl) deadlineControl.checked = false;
if (startDayControl) startDayControl.checked = false;
if (followingControl) followingControl.checked = false;
if (humanGateControl) humanGateControl.checked = false;
configuration.subscribed = false;
configuration.deadline_enabled = false;
configuration.start_day_enabled = false;
configuration.following_enabled = false;
configuration.human_gates_enabled = false;
pendingIntent = null;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
if (followingStatus) followingStatus.textContent = 'Following change alerts are off for this device.';
if (humanGateStatus) humanGateStatus.textContent = 'Human Gate decision alerts are off for this device.';
}
async function ensureSubscription() {
@ -310,38 +306,6 @@
}
}
async function changeHumanGates() {
humanGateControl.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (humanGateControl.checked) pendingIntent = 'human-gates';
if (humanGateControl.checked && !subscription) subscription = await ensureSubscription();
if (humanGateControl.checked && !subscription) {
humanGateControl.checked = false;
humanGateStatus.textContent = status.textContent;
return false;
}
await fetchJson('api/v1/push-subscription/human-gates', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:humanGateControl.checked}),
});
configuration.human_gates_enabled = humanGateControl.checked;
pendingIntent = null;
humanGateStatus.textContent = humanGateControl.checked
? 'Human Gate decision alerts enabled for this device.'
: 'Human Gate decision alerts are off for this device.';
return true;
} catch (_error) {
humanGateControl.checked = !humanGateControl.checked;
humanGateStatus.textContent = 'Could not change Human Gate alerts. Check your connection and try again.';
return false;
} finally {
humanGateControl.disabled = false;
}
}
async function changeQuietHours() {
if (!quietControl) return false;
for (const item of [quietControl, quietStart, quietEnd]) if (item) item.disabled = true;
@ -378,7 +342,7 @@
}
async function recoverPermission(intent = null) {
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following', 'human-gates'].includes(intent)) pendingIntent = intent;
if (!pendingIntent && ['updates', 'deadline', 'start-day', 'following'].includes(intent)) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
@ -394,10 +358,6 @@
followingControl.checked = true;
return changeFollowing();
}
if (pendingIntent === 'human-gates') {
humanGateControl.checked = true;
return changeHumanGates();
}
return Boolean(await enable());
})();
try {
@ -414,7 +374,6 @@
deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay);
followingControl?.addEventListener('change', changeFollowing);
humanGateControl?.addEventListener('change', changeHumanGates);
quietControl?.addEventListener('change', changeQuietHours);
quietStart?.addEventListener('change', changeQuietHours);
quietEnd?.addEventListener('change', changeQuietHours);
@ -425,7 +384,6 @@
if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true;
if (followingControl) followingControl.disabled = true;
if (humanGateControl) humanGateControl.disabled = true;
if (quietControl) quietControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
@ -434,7 +392,6 @@
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (startDayControl) startDayControl.checked = Boolean(configuration.start_day_enabled);
if (followingControl) followingControl.checked = Boolean(configuration.following_enabled);
if (humanGateControl) humanGateControl.checked = Boolean(configuration.human_gates_enabled);
if (quietControl) quietControl.checked = Boolean(configuration.quiet_hours_enabled);
if (quietStart) quietStart.value = configuration.quiet_hours_start || '22:00';
if (quietEnd) quietEnd.value = configuration.quiet_hours_end || '07:00';
@ -451,14 +408,11 @@
if (followingStatus) followingStatus.textContent = configuration.following_enabled
? 'Following change alerts enabled for this device.'
: 'Following change alerts are off for this device.';
if (humanGateStatus) humanGateStatus.textContent = configuration.human_gates_enabled
? 'Human Gate decision alerts enabled for this device.'
: 'Human Gate decision alerts are off for this device.';
if (quietStatus) quietStatus.textContent = configuration.quiet_hours_enabled
? `Routine alerts paused from ${quietStart.value} to ${quietEnd.value} local time.`
: 'Routine alert quiet hours are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeHumanGates, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
return {init, change, changeDeadline, changeStartDay, changeFollowing, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});

View File

@ -233,7 +233,6 @@
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
source_branch_deleted: 'Source branch deleted',
release_rollback_prepared: 'Release rollback prepared',
ci_job_retried: 'CI job retried',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',

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-v150';
const CACHE = 'stackchain-dashboard-shell-v139';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@ -43,7 +43,7 @@ function createAppBadgePreference() {
async getCounts() {
const database = await open();
const counts = {};
for (const channel of ['updates', 'following', 'human-gates']) {
for (const channel of ['updates', 'following']) {
counts[channel] = await new Promise((resolve, reject) => {
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('count:' + channel);
request.onsuccess = () => resolve(Number.isSafeInteger(request.result) ? request.result : 0);
@ -67,7 +67,6 @@ function createAppBadgePreference() {
async clearCounts() {
await this.setCount('updates', 0);
await this.setCount('following', 0);
await this.setCount('human-gates', 0);
},
};
}
@ -75,7 +74,7 @@ const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBa
let renderedBackgroundBadgeCount = null;
async function reconcileBackgroundAppBadge(channel, count) {
if (!['updates', 'following', 'human-gates'].includes(channel)
if (!['updates', 'following'].includes(channel)
|| !Number.isSafeInteger(count) || count < 0 || count > 9999
|| typeof self.registration.setAppBadge !== 'function'
|| typeof self.registration.clearAppBadge !== 'function') return false;
@ -85,7 +84,7 @@ async function reconcileBackgroundAppBadge(channel, count) {
try {
await appBadgePreference.setCount(channel, count);
const counts = await appBadgePreference.getCounts();
const total = Math.min(9999, counts.updates + counts.following + counts['human-gates']);
const total = Math.min(9999, counts.updates + counts.following);
if (renderedBackgroundBadgeCount === total) return false;
if (total > 0) await self.registration.setAppBadge(total);
else await self.registration.clearAppBadge();
@ -152,8 +151,6 @@ const SHELL = [
BASE + 'manifest.webmanifest',
BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js',
BASE + 'static/human-gates.js',
BASE + 'static/progressive-human-gates.js',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
@ -189,9 +186,7 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/progressive-live-snapshot.js',
BASE + 'static/progressive-my-work.js',
BASE + 'static/progressive-mobile-dock.js',
BASE + 'static/progressive-capture.js',
BASE + 'static/agenda-replan.js',
BASE + 'static/agenda-calendar.js',
@ -270,8 +265,6 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-recent-work.js',
BASE + 'static/mobile-queue-priority.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js',
BASE + 'static/mobile-start-day.js',
@ -302,8 +295,6 @@ const SHELL = [
];
const OPTIONAL_FEATURES = [
];
const DEMAND_FEATURES = [
];
const SHARED_IMAGE_ID = 'shared-image';
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
@ -628,7 +619,7 @@ self.addEventListener('message', event => {
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
const channel = event.data.channel;
const count = event.data.count;
if (!['updates', 'following', 'human-gates'].includes(channel)
if (!['updates', 'following'].includes(channel)
|| !Number.isSafeInteger(count) || count < 0 || count > 9999) return;
try {
if (await appBadgePreference.get()) {
@ -713,7 +704,6 @@ self.addEventListener('push', event => {
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
const deadlineCount = Number(payload.deadline_count);
const followingCount = Number(payload.following_count);
const humanGateCount = Number(payload.human_gate_count);
const planDate = String(payload.plan_date || '');
if (
route === '#/my-work/start-day'
@ -750,25 +740,6 @@ self.addEventListener('push', event => {
));
return;
}
if (
route === '#/my-work/human-gates'
&& tag === 'stackchain-human-gates-' + humanGateCount
&& Number.isSafeInteger(humanGateCount)
&& humanGateCount > 0
&& humanGateCount <= 50
) {
event.waitUntil(Promise.all([
reconcileBackgroundAppBadge('human-gates', humanGateCount),
self.registration.showNotification(
humanGateCount + ' release decision' + (humanGateCount === 1 ? ' is' : 's are') + ' waiting', {
body: 'Open Human Gates to review ' + (humanGateCount === 1 ? 'it.' : 'them.'),
tag,
data: {route},
}
),
]));
return;
}
if (
route === '#/my-work/following'
&& /^stackchain-following-[0-9a-f]{16}$/.test(tag)
@ -1030,8 +1001,7 @@ self.addEventListener('fetch', event => {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin &&
(OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) {
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(cachedOptionalFeature(request));
}
});

View File

@ -489,10 +489,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
const snapshot = timer.snapshot();
breakView?.render();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => {
const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])');
element.hidden = !active && !coaching;
});
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});

View File

@ -338,18 +338,8 @@ function mountTodayWeekReschedule({
confirm.addEventListener('click',async()=>{
if(!selectedDate)return;
confirm.disabled=true;status.textContent='Moving Today into Week Ahead…';dialog.close();
let result;
try{
result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
}catch(error){
if(!dialog.open)dialog.showModal();
const overload=error.message.includes('Confirm overload');allowOverload=overload;
status.textContent=error.message;
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
confirm.disabled=false;
return;
}
try{
const result=await controller.confirm(selectedDate,Number(estimate.value),{allowOverload});
if(result.sync_pending){
announce('Moved locally. Saved on this device · sync pending.');warm();
}else{
@ -357,7 +347,11 @@ function mountTodayWeekReschedule({
}
await continueToday();
}catch(error){
announce(`${error.message||'Refresh unavailable.'} Move completed; refresh to continue.`);
if(!dialog.open)dialog.showModal();
const overload=error.message.includes('Confirm overload');allowOverload=overload;
status.textContent=error.message;
confirm.textContent=overload?'Confirm overload & continue':'Move to Week Ahead & continue';
confirm.disabled=false;
}
});
return {controller,close,flushPending,resumePending};

View File

@ -19,15 +19,6 @@ async function loadWorkspace({
const failed = new Set();
const recoveries = new Map();
let retryInFlight = null;
let resolveWorkspaceReady;
const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; });
let workspaceMarkedReady = false;
const markWorkspaceReady = () => {
if (workspaceMarkedReady) return false;
workspaceMarkedReady = true;
resolveWorkspaceReady(true);
return true;
};
const loadFeature = name => {
attempts[name] += 1;
@ -72,8 +63,6 @@ async function loadWorkspace({
retryButton?.addEventListener?.('click', retryFailed);
const handleOnline = () => { cameOnline = true; void retryFailed(); };
window?.addEventListener('online', handleOnline);
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
try {
await retryOnce('work-core');
@ -84,56 +73,23 @@ async function loadWorkspace({
}
hideRecovery();
let optionalReady = null;
const hydrateWorkspace = () => {
if (optionalReady) return optionalReady;
let resolveOptional;
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
const optional = ['today-timer', 'planning'].map(async name => {
try {
await retryOnce(name);
return true;
} catch (_error) {
failed.add(name);
showRecovery();
return new Promise(resolve => recoveries.set(name, {resolve}));
}
});
Promise.all(optional).then(() => {
hideRecovery();
resolveOptional(true);
});
return optionalReady;
};
const hydrationSelector = [
'[data-mobile-task]:not([data-mobile-task="queues"])',
'[data-progressive-loading="true"]',
'#app-menu-toggle',
'#work-settings-toggle',
].join(',');
const hydrateForAction = async event => {
const target = event.target?.closest?.(hydrationSelector);
if (!target) return;
event.preventDefault();
event.stopImmediatePropagation();
await hydrateWorkspace();
await workspaceReady;
document.removeEventListener?.('click', hydrateForAction, true);
target.click?.();
};
document.addEventListener?.('click', hydrateForAction, true);
const hash = window?.location?.hash || '';
const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ?
hydrateWorkspace() : Promise.resolve(false);
const optional = ['today-timer', 'planning'].map(async name => {
try {
await retryOnce(name);
return true;
} catch (_error) {
failed.add(name);
showRecovery();
return new Promise(resolve => recoveries.set(name, {resolve}));
}
});
const optionalReady = Promise.all(optional).then(() => {
hideRecovery();
return true;
});
return {
hydrateWorkspace,
deepLinkReady,
workspaceReady,
markWorkspaceReady,
serviceWorkerReady,
get optionalReady() { return hydrateWorkspace(); },
optionalReady,
retryFeature(name) {
if (!failed.has(name)) return Promise.resolve(true);
return retryFailed().then(() => !failed.has(name));

View File

@ -78,12 +78,6 @@ STORES = (
Store("saved-searches", "saved-searches", "STACKCHAIN_SAVED_SEARCH_DB", "saved-searches.sqlite3", (
Table("saved_searches", ("login",), (Field("views", "views:{login}"),)),
)),
Store("queue-priority", "queue-priority", "STACKCHAIN_QUEUE_PRIORITY_DB", "queue-priority.sqlite3", (
Table("queue_priorities", ("login",), (Field("queue_order", "order:{login}"),)),
)),
Store("recent-work", "recent-work", "STACKCHAIN_RECENT_WORK_DB", "recent-work.sqlite3", (
Table("recent_work", ("login",), (Field("items", "items:{login}"),)),
)),
Store(
"completed-filed-reviews",
"completed-filed-reviews",

View File

@ -236,11 +236,7 @@ async def revoke_all_sessions() -> None:
async def active_devices(session: Session):
return await asyncio.to_thread(
_session_store().list_active,
session.session_id,
principal_id=session.principal_id,
)
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def session_management_id(session: Session) -> str:

View File

@ -1,35 +0,0 @@
"""Disk-capacity incident assessment shared by operations checks."""
from __future__ import annotations
import shutil
from os import PathLike
def assess_disk_capacity(
*,
total_bytes: int,
available_bytes: int,
threshold_percent: float = 85.0,
) -> dict[str, float | bool]:
"""Return the capacity status using the runbook's inclusive threshold."""
usage_percent = round((total_bytes - available_bytes) / total_bytes * 100, 1)
return {
"usage_percent": usage_percent,
"threshold_percent": threshold_percent,
"incident": usage_percent >= threshold_percent,
}
def read_disk_capacity(
path: str | PathLike[str] = "/",
*,
threshold_percent: float = 85.0,
) -> dict[str, float | bool]:
"""Assess capacity for a real filesystem path."""
usage = shutil.disk_usage(path)
return assess_disk_capacity(
total_bytes=usage.total,
available_bytes=usage.free,
threshold_percent=threshold_percent,
)

View File

@ -22,7 +22,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"work-core": (
"static/my-work.js", "static/progressive-my-work.js", "static/progressive-mobile-dock.js",
"static/my-work.js", "static/progressive-my-work.js",
"static/unfiled-captures.js", "static/progressive-capture.js",
),
"comment-actions": ("static/comment-actions.js",),
@ -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-recent-work.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/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/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
@ -140,16 +140,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
# The workspace hydrator fetches these large chunks only when a route or action
# needs them; warming them here would defeat demand loading on every visit.
optional_features = {
name: bundle for name, bundle in feature_bundles.items()
if name not in {"today-timer", "planning"}
}
demand_features = {
name: bundle for name, bundle in feature_bundles.items()
if name in {"today-timer", "planning"}
}
optional_features = feature_bundles
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
@ -160,11 +151,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = worker.replace(
"const DEMAND_FEATURES = [\n",
"const DEMAND_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
)

View File

@ -1,400 +0,0 @@
"""Durable, account-bound human release gate inbox."""
from __future__ import annotations
import base64
import hashlib
import json
import re
import sqlite3
import uuid
from pathlib import Path
from typing import Callable
from src.private_state import connect_private_sqlite
_HASH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
_PROJECT = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_STATES = {"pending", "released", "held", "superseded"}
_CHECKLIST = {"exact_hash", "artifacts_reviewed", "provenance_reviewed"}
class GateConflict(RuntimeError):
"""The gate or idempotency revision no longer matches."""
class GateValidationError(ValueError):
"""The producer or reviewer payload is not safe to persist."""
class GateNotFound(LookupError):
"""No gate exists for this account."""
def _canonical(value: object) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _fingerprint(value: object) -> str:
return hashlib.sha256(_canonical(value).encode()).hexdigest()
class HumanGateStore:
def __init__(self, path: str | Path, *, clock: Callable[[], float]):
self.path = Path(path)
self.clock = clock
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = connect_private_sqlite(self.path, timeout=2.0)
connection.row_factory = sqlite3.Row
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS human_gates (
id TEXT PRIMARY KEY, login TEXT NOT NULL, source TEXT NOT NULL,
project TEXT NOT NULL, candidate_hash TEXT NOT NULL,
title TEXT NOT NULL, priority INTEGER NOT NULL,
payload_json TEXT NOT NULL, state TEXT NOT NULL,
revision INTEGER NOT NULL, created_at REAL NOT NULL,
updated_at REAL NOT NULL, superseded_by TEXT,
decision_reason TEXT NOT NULL DEFAULT '',
override_reason TEXT NOT NULL DEFAULT '',
checklist_json TEXT NOT NULL DEFAULT '{}',
UNIQUE(login, source, project, candidate_hash)
);
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,
PRIMARY KEY(login, idempotency_key)
);
CREATE TABLE IF NOT EXISTS human_gate_history (
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,
gate_id TEXT NOT NULL, receipt_json TEXT NOT NULL,
UNIQUE(login, idempotency_key)
);
"""
)
@staticmethod
def _login(value: str) -> str:
value = str(value).strip().lower()
if not value or len(value) > 255:
raise GateValidationError("login is required")
return value
@staticmethod
def _key(value: str) -> str:
value = str(value).strip()
if not value or len(value) > 128:
raise GateValidationError("Idempotency key is required")
return value
@staticmethod
def _candidate(raw: dict) -> dict:
if not isinstance(raw, dict):
raise GateValidationError("Candidate must be an object")
source = str(raw.get("source", "")).strip()
project = str(raw.get("project", "")).strip()
candidate_hash = str(raw.get("candidate_hash", "")).strip()
title = " ".join(str(raw.get("title", "")).split())
priority = raw.get("priority", 0)
if not source or len(source) > 128:
raise GateValidationError("source is invalid")
if not _PROJECT.fullmatch(project):
raise GateValidationError("project is invalid")
if not _HASH.fullmatch(candidate_hash):
raise GateValidationError("candidate hash is invalid")
if not title or len(title) > 300:
raise GateValidationError("title is invalid")
if isinstance(priority, bool) or not isinstance(priority, int) or not 0 <= priority <= 100:
raise GateValidationError("priority is invalid")
normalized = {
"source": source, "project": project, "candidate_hash": candidate_hash,
"title": title, "priority": priority,
"artifacts": HumanGateStore._references(raw.get("artifacts", []), "name"),
"links": HumanGateStore._references(raw.get("links", []), "label"),
"checks": HumanGateStore._checks(raw.get("checks", [])),
"score": raw.get("score") if isinstance(raw.get("score"), dict) else {},
"provenance": raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {},
}
if len(_canonical(normalized)) > 100_000:
raise GateValidationError("Candidate payload is too large")
return normalized
@staticmethod
def _references(raw: object, label: str) -> list[dict]:
if not isinstance(raw, list) or len(raw) > 50:
raise GateValidationError("references are invalid")
result = []
for item in raw:
if not isinstance(item, dict):
raise GateValidationError("reference is invalid")
name, url = str(item.get(label, "")).strip(), str(item.get("url", "")).strip()
if not name or len(name) > 200 or not url.startswith("https://") or len(url) > 2048:
raise GateValidationError("reference is invalid")
result.append({label: name, "url": url})
return result
@staticmethod
def _checks(raw: object) -> list[dict]:
if not isinstance(raw, list) or len(raw) > 100:
raise GateValidationError("checks are invalid")
result = []
for item in raw:
if not isinstance(item, dict):
raise GateValidationError("check is invalid")
name, state = str(item.get("name", "")).strip(), item.get("state")
if not name or len(name) > 200 or state not in {"success", "failure", "pending", "skipped"}:
raise GateValidationError("check is invalid")
result.append({"name": name, "state": state, "required": bool(item.get("required", True))})
return result
def _history(self, connection: sqlite3.Connection, gate_id: str) -> list[dict]:
return [
{"sequence": row[0], "action": row[1], "at": row[2], **json.loads(row[3])}
for row in connection.execute(
"SELECT sequence, action, at, details_json FROM human_gate_history WHERE gate_id=? ORDER BY sequence",
(gate_id,),
)
]
def _present(self, connection: sqlite3.Connection, row: sqlite3.Row, *, history: bool = False) -> dict:
payload = json.loads(row["payload_json"])
item = {
"id": row["id"], **payload, "state": row["state"], "revision": row["revision"],
"created_at": row["created_at"], "updated_at": row["updated_at"],
"superseded_by": row["superseded_by"],
}
if row["state"] in {"released", "held"}:
item.update({
"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
def has_intake_key(self, login: str, idempotency_key: str) -> bool:
with self._connect() as connection:
return connection.execute(
"SELECT 1 FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
(self._login(login), self._key(idempotency_key)),
).fetchone() is not None
def intake(self, login: str, raw: dict, *, idempotency_key: str) -> dict:
login, key, payload = self._login(login), self._key(idempotency_key), self._candidate(raw)
fingerprint = _fingerprint(payload)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
prior = connection.execute(
"SELECT fingerprint, gate_id FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
(login, key),
).fetchone()
if prior:
if prior["fingerprint"] != fingerprint:
raise GateConflict("Idempotency key was already used for another candidate")
row = connection.execute("SELECT * FROM human_gates WHERE id=? AND login=?", (prior["gate_id"], login)).fetchone()
return self._present(connection, row, history=True)
existing = connection.execute(
"SELECT * FROM human_gates WHERE login=? AND source=? AND project=? AND candidate_hash=?",
(login, payload["source"], payload["project"], payload["candidate_hash"]),
).fetchone()
if existing:
old_payload = json.loads(existing["payload_json"])
immutable_old = {key: value for key, value in old_payload.items() if key != "checks"}
immutable_new = {key: value for key, value in payload.items() if key != "checks"}
if immutable_old != immutable_new:
raise GateConflict("Candidate hash is already bound to different facts")
if old_payload != payload:
if existing["state"] == "superseded":
raise GateConflict("Candidate hash was superseded by a newer candidate")
now = float(self.clock())
revision = existing["revision"] + 1
reopened = existing["state"] in {"released", "held"}
state = "pending" if reopened else existing["state"]
action = "reopened" if reopened else "updated"
connection.execute(
"UPDATE human_gates SET payload_json=?, state=?, revision=?, updated_at=?, decision_reason='', override_reason='', checklist_json='{}' WHERE id=?",
(_canonical(payload), state, revision, now, existing["id"]),
)
connection.execute(
"INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)",
(existing["id"], action, now, _canonical({"candidate_hash": payload["candidate_hash"]})),
)
existing = connection.execute(
"SELECT * FROM human_gates WHERE id=? AND login=?",
(existing["id"], login),
).fetchone()
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, existing["id"]))
return self._present(connection, existing, history=True)
now, gate_id = float(self.clock()), str(uuid.uuid4())
old_rows = connection.execute(
"SELECT id, revision FROM human_gates WHERE login=? AND source=? AND project=? AND state='pending'",
(login, payload["source"], payload["project"]),
).fetchall()
connection.execute(
"INSERT INTO human_gates(id,login,source,project,candidate_hash,title,priority,payload_json,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?, 'pending',1,?,?)",
(gate_id, login, payload["source"], payload["project"], payload["candidate_hash"], payload["title"], payload["priority"], _canonical(payload), now, now),
)
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, "intake", now, _canonical({"candidate_hash": payload["candidate_hash"]})))
for old in old_rows:
connection.execute("UPDATE human_gates SET state='superseded', revision=?, updated_at=?, superseded_by=? WHERE id=? AND state='pending'", (old["revision"] + 1, now, gate_id, old["id"]))
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (old["id"], "superseded", now, _canonical({"superseded_by": gate_id, "candidate_hash": payload["candidate_hash"]})))
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, gate_id))
row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone()
return self._present(connection, row, history=True)
@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 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, "
"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:
with self._connect() as connection:
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (self._login(login), gate_id)).fetchone()
if row is None:
raise GateNotFound("Gate not found")
return self._present(connection, row, history=True)
def decide(self, login: str, gate_id: str, *, expected_revision: int, decision: str, reason: str, override_reason: str, checklist: dict, idempotency_key: str) -> dict:
login, key = self._login(login), self._key(idempotency_key)
reason, override_reason = str(reason).strip(), str(override_reason).strip()
if decision not in {"release", "hold"}:
raise GateValidationError("decision is invalid")
if decision == "hold" and not reason:
raise GateValidationError("Hold reason is required")
if decision == "release" and (
not isinstance(checklist, dict)
or set(checklist) != _CHECKLIST
or not all(value is True for value in checklist.values())
):
raise GateValidationError("A complete release checklist is required")
if decision == "hold":
checklist = {}
request = {"gate_id": gate_id, "expected_revision": expected_revision, "decision": decision, "reason": reason, "override_reason": override_reason, "checklist": checklist}
fingerprint = _fingerprint(request)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
prior = connection.execute("SELECT fingerprint, receipt_json FROM human_gate_receipts WHERE login=? AND idempotency_key=?", (login, key)).fetchone()
if prior:
if prior["fingerprint"] != fingerprint:
raise GateConflict("Idempotency key was already used for another decision")
return json.loads(prior["receipt_json"])
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (login, gate_id)).fetchone()
if row is None:
raise GateNotFound("Gate not found")
if row["state"] != "pending" or row["revision"] != expected_revision:
raise GateConflict("Gate revision is stale")
payload = json.loads(row["payload_json"])
unmet = [check["name"] for check in payload["checks"] if check["required"] and check["state"] != "success"]
if decision == "release" and unmet and not override_reason:
raise GateValidationError("An explicit override reason is required for unmet checks")
now, receipt_id, revision = float(self.clock()), str(uuid.uuid4()), row["revision"] + 1
state = "released" if decision == "release" else "held"
connection.execute("UPDATE human_gates SET state=?,revision=?,updated_at=?,decision_reason=?,override_reason=?,checklist_json=? WHERE id=?", (state, revision, now, reason, override_reason, _canonical(checklist), gate_id))
receipt = {"receipt_id": receipt_id, "gate_id": gate_id, "candidate_hash": row["candidate_hash"], "state": state, "revision": revision, "decided_at": now, "reason": reason, "override_reason": override_reason, "checklist": checklist, "unmet_required_checks": unmet}
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, state, now, _canonical({"receipt_id": receipt_id, "reason": reason, "override_reason": override_reason, "unmet_required_checks": unmet})))
connection.execute("INSERT INTO human_gate_receipts VALUES (?,?,?,?,?,?)", (receipt_id, login, key, fingerprint, gate_id, _canonical(receipt)))
return receipt
def has_receipt_key(self, login: str, idempotency_key: str) -> bool:
with self._connect() as connection:
return connection.execute(
"SELECT 1 FROM human_gate_receipts WHERE login=? AND idempotency_key=?",
(self._login(login), self._key(idempotency_key)),
).fetchone() is not None
def receipt(self, login: str, receipt_id: str) -> dict:
with self._connect() as connection:
row = connection.execute("SELECT receipt_json FROM human_gate_receipts WHERE login=? AND receipt_id=?", (self._login(login), receipt_id)).fetchone()
if row is None:
raise GateNotFound("Receipt not found")
return json.loads(row[0])

View File

@ -42,12 +42,6 @@ from src.gitea_proxy import (
repos,
)
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.human_gate_store import (
GateConflict,
GateNotFound,
GateValidationError,
HumanGateStore,
)
from src.image_sanitizer import sanitize_image
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import (
@ -62,7 +56,6 @@ from src.push_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
dispatch_following_changes,
dispatch_human_gate_changes,
dispatch_start_day_reminders,
dispatch_unread_updates,
send_web_push,
@ -71,8 +64,6 @@ from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_en
from src.push_subscription_store import build_push_subscription_store
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.queue_priority_store import QueuePriorityConflict, QueuePriorityStore
from src.recent_work_store import RecentWorkStore
from src.following_store import FollowingStore
from src.unfiled_draft_store import (
UnfiledDraftConflict,
@ -145,18 +136,6 @@ async def _following_push_snapshot() -> dict:
return await get_following(Response())
async def _human_gate_push_snapshot() -> dict:
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
return {"complete": False}
result = await asyncio.to_thread(
_human_gate_store().list, login, state="pending", limit=100
)
items = result.get("items", []) if isinstance(result, dict) else []
return {"complete": True, "count": len(items)}
async def _identity_bound_push_session_statuses(
management_ids: list[str],
) -> dict[str, str]:
@ -222,17 +201,6 @@ async def _push_poll_loop() -> None:
max_concurrency=max_concurrency,
)
async def dispatch_human_gates() -> None:
await dispatch_human_gate_changes(
_push_subscription_store,
_push_configuration(),
_human_gate_push_snapshot,
session_statuses=_identity_bound_push_session_statuses,
send_timeout_seconds=send_timeout,
lease_seconds=lease_seconds,
max_concurrency=max_concurrency,
)
async def dispatch_start_day() -> None:
await dispatch_start_day_reminders(
_push_subscription_store,
@ -247,7 +215,6 @@ async def _push_poll_loop() -> None:
channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
asyncio.create_task(_push_channel_loop(dispatch_following, interval=interval)),
asyncio.create_task(_push_channel_loop(dispatch_human_gates, interval=interval)),
asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
),
@ -313,7 +280,6 @@ app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
CONTEXT_TIMEOUT_SECONDS = 5.0
LIVE_SNAPSHOT_DEADLINE_GRACE_SECONDS = 0.1
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
READINESS_INTERVAL_SECONDS = max(
@ -475,14 +441,6 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
class HumanGateDecision(BaseModel):
expected_revision: PositiveInt
decision: Literal["release", "hold"]
reason: str = Field(default="", max_length=2_000)
override_reason: str = Field(default="", max_length=2_000)
checklist: dict[str, bool]
async def _upstream_identity() -> tuple[int, str]:
upstream = await current_user()
principal_id = upstream.get("id") if isinstance(upstream, dict) else None
@ -498,15 +456,6 @@ 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)
@ -573,10 +522,6 @@ class FollowingNotificationPayload(BaseModel):
enabled: bool
class HumanGateNotificationPayload(BaseModel):
enabled: bool
class QuietHoursPayload(BaseModel):
enabled: bool
start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
@ -602,11 +547,9 @@ class QuietHoursPayload(BaseModel):
StepUpAction = Literal[
"decide_human_gate",
"merge_pull",
"delete_source_branch",
"prepare_release_rollback",
"retry_ci_job",
"submit_pull_review",
"close_issue",
"delete_comment",
@ -646,17 +589,6 @@ class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
pass
def _human_gate_store() -> HumanGateStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
return HumanGateStore(
os.getenv(
"STACKCHAIN_HUMAN_GATE_DB",
os.path.join(state_dir, "human-gates.sqlite3"),
),
clock=time.time,
)
def _passkey_store() -> PasskeyStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
database = os.getenv(
@ -968,23 +900,6 @@ class SavedSearchCollection(BaseModel):
views: list[SavedSearchView] = Field(max_length=20)
class QueuePriorityCollection(BaseModel):
revision: int = Field(ge=0)
order: list[str] = Field(min_length=9, max_length=9)
class RecentWorkItem(BaseModel):
kind: Literal["issue", "filed", "pull", "review", "update"]
repository: str = Field(default="", max_length=200)
number: PositiveInt
title: str = Field(min_length=1, max_length=180)
route: str = Field(min_length=1, max_length=300)
class RecentWorkRoute(BaseModel):
route: str = Field(min_length=1, max_length=300)
class CompletedFiledReviewReceipt(BaseModel):
repository: str = Field(
min_length=3,
@ -1804,7 +1719,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/queue-priority", "/api/v1/recent-work", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
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/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@ -1876,143 +1791,6 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "stackchain-dashboard"}
async def _human_gate_login(request: Request) -> str:
session = getattr(request.state, "dashboard_session", None)
if session is not None and session.principal_login and session.principal_id:
return f"{session.principal_id}:{session.principal_login}"
principal_id, login = await _upstream_identity()
return f"{principal_id}:{login}"
def _gate_error(error: Exception) -> HTTPException:
if isinstance(error, sqlite3.Error):
return HTTPException(status_code=503, detail="Human Gates are temporarily unavailable")
if isinstance(error, GateNotFound):
return HTTPException(status_code=404, detail=str(error))
if isinstance(error, GateConflict):
return HTTPException(status_code=409, detail=str(error))
return HTTPException(status_code=422, detail=str(error))
@app.post("/api/v1/human-gates/intake")
async def intake_human_gate(
payload: dict,
request: Request,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
):
login = await _human_gate_login(request)
try:
store = _human_gate_store()
existed = await asyncio.to_thread(store.has_intake_key, login, idempotency_key)
gate = await asyncio.to_thread(
store.intake, login, payload, idempotency_key=idempotency_key
)
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(
gate, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/human-gates")
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, cursor=cursor
)
except (GateValidationError, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/human-gates/{gate_id}")
async def human_gate_detail(gate_id: str, request: Request):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().detail, login, gate_id)
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/human-gates/{gate_id}/decision")
async def decide_human_gate(
gate_id: str,
payload: HumanGateDecision,
request: Request,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
await _require_step_up(
request,
step_up_grant,
action="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:
store = _human_gate_store()
existed = await asyncio.to_thread(store.has_receipt_key, login, idempotency_key)
if not existed:
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"human_gate_decision",
principal_id=principal_id,
method=payload.decision,
target=gate_id,
)
except SecurityEventStoreError as error:
raise HTTPException(
status_code=503,
detail="Security activity is temporarily unavailable",
) from error
receipt = await asyncio.to_thread(
store.decide,
login,
gate_id,
expected_revision=payload.expected_revision,
decision=payload.decision,
reason=payload.reason,
override_reason=payload.override_reason,
checklist=payload.checklist,
idempotency_key=idempotency_key,
)
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
if operation_id is not None:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise _gate_error(error) from error
if operation_id is not None:
await asyncio.to_thread(journal.finalize, operation_id)
return JSONResponse(
receipt, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/human-gate-receipts/{receipt_id}")
async def human_gate_receipt(receipt_id: str, request: Request):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().receipt, login, receipt_id)
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/session")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
peer_host = request.client.host if request.client is not None else "unknown"
@ -2093,7 +1871,6 @@ 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",
@ -2131,16 +1908,12 @@ 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,
principal_id=await _security_principal_id(request),
limit=limit,
cursor=cursor,
_security_event_store().list, limit=limit, cursor=cursor
)
authentication_alerts = await asyncio.to_thread(
_login_attempt_store().list_alerts, limit=24
@ -2247,9 +2020,7 @@ async def create_passkey_registration_options(
)
rp_id, _origin = _passkey_relying_party(request)
store = _passkey_store()
existing = await asyncio.to_thread(
store.all, principal_id=request.state.dashboard_session.principal_id
)
existing = await asyncio.to_thread(store.all)
options, challenge = passkeys.registration_options(
rp_id=rp_id,
excluded=[item.credential_id for item in existing],
@ -2306,7 +2077,6 @@ 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",
@ -2323,7 +2093,6 @@ 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:
@ -2345,10 +2114,7 @@ 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,
principal_id=request.state.dashboard_session.principal_id,
)
credentials = await asyncio.to_thread(_passkey_store().all)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@ -2395,11 +2161,7 @@ async def revoke_enrolled_passkey(
)
store = _passkey_store()
try:
credential = await asyncio.to_thread(
store.get_management_id,
management_id,
principal_id=request.state.dashboard_session.principal_id,
)
credential = await asyncio.to_thread(store.get_management_id, management_id)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@ -2418,7 +2180,6 @@ 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",
)
@ -2466,16 +2227,8 @@ 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, principal_id=principal_id)
credentials = await asyncio.to_thread(store.all)
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"
@ -2528,9 +2281,7 @@ async def create_passkey_authorization_options(
payload: PasskeyAuthorizationTarget, request: Request
):
store = _passkey_store()
credentials = await asyncio.to_thread(
store.all, principal_id=request.state.dashboard_session.principal_id
)
credentials = await asyncio.to_thread(store.all)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
rp_id, _origin = _passkey_relying_party(request)
@ -2567,11 +2318,7 @@ async def verify_passkey_authorization(
action=payload.action,
target=payload.target,
)
stored = await asyncio.to_thread(
store.get,
credential_id,
principal_id=request.state.dashboard_session.principal_id,
)
stored = await asyncio.to_thread(store.get, credential_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)
@ -2594,7 +2341,6 @@ 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}",
@ -2657,14 +2403,6 @@ 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,
@ -2673,9 +2411,7 @@ async def verify_passkey_authentication(
action="sign_in",
target="dashboard",
)
stored = await asyncio.to_thread(
store.get, credential_id, principal_id=principal_id
)
stored = await asyncio.to_thread(store.get, credential_id)
if not valid or stored is None:
try:
await asyncio.to_thread(attempts.record_failure, source)
@ -2706,12 +2442,19 @@ 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,
@ -2745,7 +2488,6 @@ 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",
@ -2811,9 +2553,6 @@ async def push_status(request: Request):
following_preferences = await asyncio.to_thread(
_push_subscription_store.following_preferences, device_id
)
human_gate_preferences = await asyncio.to_thread(
_push_subscription_store.human_gate_preferences, device_id
)
quiet_hours = await asyncio.to_thread(
_push_subscription_store.quiet_hours, device_id
)
@ -2833,7 +2572,6 @@ async def push_status(request: Request):
"start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
"following_enabled": following_preferences["enabled"],
"human_gates_enabled": human_gate_preferences["enabled"],
"quiet_hours_enabled": quiet_hours["enabled"],
"quiet_hours_start": quiet_hours["start"],
"quiet_hours_end": quiet_hours["end"],
@ -3005,25 +2743,6 @@ async def update_following_notifications(
return {"following_enabled": payload.enabled}
@app.put("/api/v1/push-subscription/human-gates")
async def update_human_gate_notifications(
payload: HumanGateNotificationPayload, request: Request
):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
if payload.enabled and not await asyncio.to_thread(
_push_subscription_store.is_subscribed, device_id
):
raise HTTPException(status_code=409, detail="Enable device notifications first")
await asyncio.to_thread(
_push_subscription_store.set_human_gate_preferences,
device_id,
enabled=payload.enabled,
)
return {"human_gates_enabled": payload.enabled}
@app.put("/api/v1/push-subscription/quiet-hours")
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
device_id = await dashboard_auth.session_management_id(
@ -3122,18 +2841,6 @@ def _saved_search_store() -> SavedSearchStore:
)
def _queue_priority_store() -> QueuePriorityStore:
return QueuePriorityStore(
os.getenv("STACKCHAIN_QUEUE_PRIORITY_DB", str(_state_dir / "queue-priority.sqlite3"))
)
def _recent_work_store() -> RecentWorkStore:
return RecentWorkStore(
os.getenv("STACKCHAIN_RECENT_WORK_DB", str(_state_dir / "recent-work.sqlite3"))
)
def _following_store() -> FollowingStore:
return FollowingStore(
os.getenv("STACKCHAIN_FOLLOWING_DB", str(_state_dir / "following.sqlite3"))
@ -3401,112 +3108,6 @@ async def replace_saved_searches(payload: SavedSearchCollection):
)
@app.get("/api/v1/queue-priority")
async def get_queue_priority(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_queue_priority_store().get, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Queue priority synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@app.put("/api/v1/queue-priority")
async def replace_queue_priority(payload: QueuePriorityCollection):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_queue_priority_store().replace, login, payload.revision, payload.order
)
except QueuePriorityConflict as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Queue priority changed on another device.",
"snapshot": exc.snapshot,
},
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Queue priority synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/recent-work")
async def get_recent_work(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_recent_work_store().get, login)
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Recent work synchronization is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return snapshot
@app.post("/api/v1/recent-work")
async def record_recent_work(payload: RecentWorkItem):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_recent_work_store().record, login, payload.model_dump()
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Recent work synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.put("/api/v1/recent-work/pin")
async def pin_recent_work(payload: RecentWorkItem):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_recent_work_store().pin, login, payload.model_dump()
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Recent work synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.delete("/api/v1/recent-work/pin")
async def unpin_recent_work(payload: RecentWorkRoute):
login = await _confirmed_login()
try:
return await asyncio.to_thread(
_recent_work_store().unpin, login, payload.route
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Recent work synchronization is unavailable",
headers={"Retry-After": "1"},
)
@app.get("/api/v1/unfiled-drafts")
async def get_unfiled_drafts(response: Response):
login = await _confirmed_login()
@ -3965,7 +3566,6 @@ 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:
@ -4066,7 +3666,6 @@ 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:
@ -4177,7 +3776,6 @@ 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",
)
@ -4189,9 +3787,7 @@ async def revoke_active_device(
)
try:
revoked = await asyncio.to_thread(
_passkey_store().revoke_device_access,
management_id,
principal_id=request.state.dashboard_session.principal_id,
_passkey_store().revoke_device_access, management_id
)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@ -4230,7 +3826,6 @@ 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:
@ -4240,17 +3835,14 @@ async def sign_out_all_devices(
headers={"Cache-Control": "no-store"},
)
try:
management_ids = await asyncio.to_thread(
_passkey_store().revoke_all_access,
principal_id=request.state.dashboard_session.principal_id,
)
await asyncio.to_thread(_passkey_store().revoke_all_access)
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_sessions, management_ids)
await asyncio.to_thread(_push_subscription_store.delete_all)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
@ -5082,43 +4674,28 @@ async def _load_context_for_user(user_data: dict) -> dict:
async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
requested = set(sections or LIVE_SNAPSHOT_SECTIONS)
results: dict[str, object] = {}
user_task: asyncio.Task | None = None
user_data: dict | None = None
if requested & {"context", "events"}:
user_task = asyncio.create_task(current_user())
async def load_user_section(section: str) -> object:
assert user_task is not None
user_data = await asyncio.shield(user_task)
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
if section == "context":
return await _load_context_for_user(user_data)
return await activity_events(user_data)
async def load_before_deadline(load: Awaitable[Any]) -> object:
try:
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
return await load
user_data = await current_user()
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
except Exception as exc:
return exc
for section in requested & {"context", "events"}:
results[section] = exc
loads: dict[str, Awaitable[Any]] = {}
if "context" in requested:
loads["context"] = load_user_section("context")
if "events" in requested:
loads["events"] = load_user_section("events")
if "context" in requested and "context" not in results:
assert user_data is not None
loads["context"] = _load_context_for_user(user_data)
if "events" in requested and "events" not in results:
assert user_data is not None
loads["events"] = activity_events(user_data)
if "notifications" in requested:
loads["notifications"] = notifications()
try:
if loads:
loaded = await asyncio.gather(
*(load_before_deadline(load) for load in loads.values())
)
results.update(zip(loads, loaded))
finally:
if user_task is not None and not user_task.done():
user_task.cancel()
await asyncio.gather(user_task, return_exceptions=True)
if loads:
loaded = await asyncio.gather(*loads.values(), return_exceptions=True)
results.update(zip(loads, loaded))
context_result = results.get("context")
events_result = results.get("events")
@ -5157,9 +4734,7 @@ async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
async def _build_live_snapshot_before_deadline(sections: set[str]) -> dict:
async with asyncio.timeout(
CONTEXT_TIMEOUT_SECONDS + LIVE_SNAPSHOT_DEADLINE_GRACE_SECONDS
):
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
return await _build_live_snapshot(sections)
@ -6804,7 +6379,6 @@ 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:
@ -7260,7 +6834,6 @@ 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:
@ -7864,38 +7437,13 @@ async def pull_action_failure(
)
async def retry_pull_action_job(
retry: PullReadyRequest,
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
) -> JSONResponse:
repository = f"{owner}/{repo}"
target = (
f"{repository}#{number}@{retry.expected_head_sha}:"
f"actions/{run_id}/jobs/{job_index}"
)
await _require_step_up(
request, step_up_grant, action="retry_ci_job", target=target
)
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"ci_job_retried",
principal_id=await _security_principal_id(request),
target=target,
)
except SecurityEventStoreError:
return JSONResponse(
{"error": "Security activity is temporarily unavailable. No job was retried."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
async def retry_job():
if not _has_pull_workspace_access(
@ -7911,26 +7459,14 @@ async def retry_pull_action_job(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise
except gitea_proxy.StalePullError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "New commits arrived. Reload checks before retrying this job."},
status_code=409,
headers={"Cache-Control": "no-store"},
)
except ValueError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "This check is no longer failed or cannot be retried."},
status_code=409,
@ -7942,10 +7478,6 @@ async def retry_pull_action_job(
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"}
)
@ -8207,7 +7739,6 @@ 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:
@ -8299,7 +7830,6 @@ 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:
@ -8401,39 +7931,14 @@ async def release_action_failure(
"/checks/{run_id}/jobs/{job_index}/retry"
)
async def retry_release_action_job(
request: Request,
owner: str,
repo: str,
number: int = PathParam(gt=0),
commit_sha: str = PathParam(min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"),
run_id: int = PathParam(gt=0),
job_index: int = PathParam(ge=0),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
) -> JSONResponse:
repository = f"{owner}/{repo}"
target = (
f"{repository}#{number}@{commit_sha}:"
f"actions/{run_id}/jobs/{job_index}"
)
await _require_step_up(
request, step_up_grant, action="retry_ci_job", target=target
)
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"ci_job_retried",
principal_id=await _security_principal_id(request),
target=target,
)
except SecurityEventStoreError:
return JSONResponse(
{"error": "Security activity is temporarily unavailable. No job was retried."},
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
async def retry_job():
if not await gitea_proxy.can_recover_merged_release(
@ -8449,16 +7954,8 @@ async def retry_release_action_job(
retry_job(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise
except ValueError:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
{"error": "This release check is no longer failed or cannot be retried."},
status_code=409,
@ -8470,10 +7967,6 @@ async def retry_release_action_job(
status_code=503,
headers={"Cache-Control": "no-store", "Retry-After": "1"},
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
return JSONResponse(
result, status_code=202, headers={"Cache-Control": "no-store"}
)
@ -8524,10 +8017,7 @@ async def prepare_release_rollback(
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"release_rollback_prepared",
principal_id=await _security_principal_id(request),
target=target,
journal.reserve, "release_rollback_prepared", target=target
)
except SecurityEventStoreError:
return JSONResponse(
@ -8625,7 +8115,6 @@ 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:

View File

@ -18,7 +18,6 @@ class StoredPasskey:
device_label: str
management_id: str
created_at: int
principal_id: int
class PasskeyStore:
@ -51,19 +50,10 @@ class PasskeyStore:
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
principal_id INTEGER
created_at INTEGER NOT NULL
)
"""
)
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 (
@ -182,14 +172,12 @@ 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, principal_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
@ -197,48 +185,41 @@ 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, *, principal_id: int) -> list[StoredPasskey]:
def all(self) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"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,),
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials ORDER BY created_at DESC"
).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, *, principal_id: int) -> StoredPasskey | None:
def get(self, credential_id: bytes) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"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),
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_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, *, principal_id: int
) -> StoredPasskey | None:
def get_management_id(self, management_id: str) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"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),
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
@ -322,14 +303,13 @@ 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, *, principal_id: int) -> bool:
def revoke_device_access(self, management_id: str) -> 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 = ? AND principal_id = ?",
(management_id, principal_id),
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if session is None:
return False
@ -337,44 +317,22 @@ class PasskeyStore:
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
connection.execute(
"DELETE FROM passkey_credentials "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
cursor = connection.execute(
"DELETE FROM active_sessions "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
"DELETE FROM active_sessions WHERE management_id = ?", (management_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, *, principal_id: int) -> list[str]:
"""Atomically remove one principal's passkeys, grants, and active sessions."""
def revoke_all_access(self) -> None:
"""Atomically remove every passkey, challenge, grant, and active session."""
try:
with self._connect() as connection:
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
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")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc

View File

@ -240,104 +240,6 @@ async def dispatch_following_changes(
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
async def dispatch_human_gate_changes(
store: PushSubscriptionStore,
configuration: PushConfiguration,
pending_gates: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
now: float | None = None,
) -> int:
"""Notify opted-in active devices when the pending Human Gate count changes."""
if not configuration.enabled:
return 0
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="human-gates",
now=time.time() if now is None else now,
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not acquired:
return 0
try:
devices = await asyncio.to_thread(store.human_gate_notification_devices, now=now)
if not devices:
return 0
snapshot = await pending_gates()
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
return 0
count = snapshot.get("count")
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
return 0
count = min(count, 50)
pending = [device for device in devices if device.delivered_count != count]
if not pending:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses([device.session_id for device in pending])
except Exception:
return 0
for device in pending:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
pending = [device for device in pending if statuses.get(device.session_id) == "active"]
if count == 0:
await asyncio.gather(*(
asyncio.to_thread(store.mark_human_gate_delivered, device.session_id, 0)
for device in pending
))
return 0
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
payload = json.dumps({
"title": f"{count} release decision{' is' if count == 1 else 's are'} waiting",
"body": f"Open Human Gates to review {'it' if count == 1 else 'them'}.",
"route": "#/my-work/human-gates",
"tag": f"stackchain-human-gates-{count}",
"human_gate_count": count,
}, separators=(",", ":"))
try:
operation = (
send(device.subscription, payload)
if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(store.delete_session, device.session_id)
else:
await asyncio.to_thread(
store.mark_delivery_failed, device.session_id, "human-gates",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "human-gates"
)
await asyncio.to_thread(
store.mark_human_gate_delivered, device.session_id, count
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device in pending), return_exceptions=True
)
return sum(result for result in results if isinstance(result, int))
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="human-gates")
async def dispatch_unread_updates(
store: PushSubscriptionStore,
configuration: PushConfiguration,

View File

@ -63,14 +63,6 @@ class FollowingNotificationDevice:
catch_up: bool = False
@dataclass(frozen=True)
class HumanGateNotificationDevice:
session_id: str
subscription: dict
delivered_count: int
catch_up: bool = False
class DisabledPushSubscriptionStore:
"""No-persistence store used when Web Push is not configured."""
@ -103,18 +95,12 @@ class DisabledPushSubscriptionStore:
def following_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def human_gate_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def quiet_hours(self, session_id: str) -> dict:
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
return []
def human_gate_notification_devices(self, *, now: float | None = None) -> list[HumanGateNotificationDevice]:
return []
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
return []
@ -133,9 +119,6 @@ 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
@ -157,18 +140,12 @@ class DisabledPushSubscriptionStore:
def set_following_preferences(self, *args, **kwargs) -> None:
return None
def set_human_gate_preferences(self, *args, **kwargs) -> None:
return None
def set_quiet_hours(self, *args, **kwargs) -> None:
return None
def mark_following_delivered(self, *args, **kwargs) -> None:
return None
def mark_human_gate_delivered(self, *args, **kwargs) -> None:
return None
def reconcile_unread(self, *args, **kwargs) -> None:
return None
@ -305,13 +282,6 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_human_gate_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
delivered_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_quiet_hours (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
@ -445,17 +415,6 @@ 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")
@ -683,23 +642,6 @@ class PushSubscriptionStore:
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def set_human_gate_preferences(self, session_id: str, *, enabled: bool) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_human_gate_preferences(session_id, enabled)
VALUES (?, ?)
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
(session_id, int(enabled)),
)
def human_gate_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT enabled FROM push_human_gate_preferences WHERE session_id = ?",
(session_id,),
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def set_quiet_hours(
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
) -> None:
@ -771,46 +713,6 @@ class PushSubscriptionStore:
(session_id,),
)
def human_gate_notification_devices(
self, *, now: float | None = None
) -> list[HumanGateNotificationDevice]:
checked_at = time.time() if now is None else now
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.delivered_count,
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
FROM push_subscriptions s
JOIN push_human_gate_preferences p ON p.session_id = s.session_id
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
devices = []
for row in rows:
if row[3] and _inside_quiet_hours(
now=checked_at, start=row[4], end=row[5], timezone=row[6]
):
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
(row[0],),
)
continue
devices.append(HumanGateNotificationDevice(
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
))
return devices
def mark_human_gate_delivered(self, session_id: str, count: int) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_human_gate_preferences SET delivered_count = ?
WHERE session_id = ? AND enabled = 1""",
(count, session_id),
)
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
(session_id,),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
*, now: float | None = None,

View File

@ -1,100 +0,0 @@
"""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}

View File

@ -1,178 +0,0 @@
"""Encrypted, account-scoped recent work shared by signed-in devices."""
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
KINDS = frozenset({"issue", "filed", "pull", "review", "update"})
class RecentWorkStore:
def __init__(
self,
path: str | Path,
*,
timeout: float = 1.0,
encryption_key: bytes | None = None,
limit: int = 5,
pinned_limit: int = 20,
):
self.path = Path(path)
self.timeout = timeout
self.limit = max(1, int(limit))
self.pinned_limit = max(1, int(pinned_limit))
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="recent-work",
)
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"CREATE TABLE IF NOT EXISTS recent_work ("
"login TEXT PRIMARY KEY, items TEXT NOT NULL)"
)
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
@staticmethod
def _login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _normalize(item: dict) -> dict:
if not isinstance(item, dict):
raise ValueError("recent work item is invalid")
kind = item.get("kind")
number = item.get("number")
title = item.get("title")
repository = item.get("repository", "")
if (
kind not in KINDS
or not isinstance(number, int)
or isinstance(number, bool)
or number < 1
or not isinstance(title, str)
or not title.strip()
):
raise ValueError("recent work item is invalid")
title = title.strip()[:180]
if kind == "update":
if repository:
raise ValueError("recent work item is invalid")
route = f"#/my-work/update/{number}"
normalized = {"kind": kind, "number": number, "title": title, "route": route}
else:
if (
not isinstance(repository, str)
or repository.count("/") != 1
or any(not part or not all(character.isalnum() or character in "_.-" for character in part)
for part in repository.split("/"))
):
raise ValueError("recent work item is invalid")
route = f"#/my-work/{kind}/{repository}/{number}"
normalized = {
"kind": kind,
"repository": repository,
"number": number,
"title": title,
"route": route,
}
if item.get("route", route) != route:
raise ValueError("recent work item is invalid")
return normalized
def _state(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"items": [], "pinned": []}, False
payload, legacy = self._cipher.open(row[0], binding=f"items:{login}")
if isinstance(payload, list):
payload = {"items": payload, "pinned": []}
legacy = True
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list) or not isinstance(payload.get("pinned"), list):
raise PrivateStateEncryptionError("private state could not be decrypted")
try:
items = [self._normalize(item) for item in payload["items"]][: self.limit]
pinned = [self._normalize(item) for item in payload["pinned"]][: self.pinned_limit]
if len({item["route"] for item in pinned}) != len(pinned):
raise ValueError("recent work item is invalid")
return {"items": items, "pinned": pinned}, legacy
except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error
def _seal(self, state: dict, login: str) -> str:
return self._cipher.seal(state, binding=f"items:{login}")
def _write(self, connection: sqlite3.Connection, login: str, state: dict) -> None:
connection.execute(
"INSERT INTO recent_work(login, items) VALUES (?, ?) "
"ON CONFLICT(login) DO UPDATE SET items=excluded.items",
(login, self._seal(state, login)),
)
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
state, legacy = self._state(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE recent_work SET items = ? WHERE login = ? AND items = ?",
(self._seal(state, login), login, row[0]),
)
return state
def record(self, login: str, item: dict) -> dict:
login = self._login(login)
normalized = self._normalize(item)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
state, _legacy = self._state(row, login)
state["items"] = [normalized, *(entry for entry in state["items"] if entry["route"] != normalized["route"])][: self.limit]
state["pinned"] = [
normalized,
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
] if any(entry["route"] == normalized["route"] for entry in state["pinned"]) else state["pinned"]
self._write(connection, login, state)
return state
def pin(self, login: str, item: dict) -> dict:
login = self._login(login)
normalized = self._normalize(item)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
state, _legacy = self._state(row, login)
state["pinned"] = [
normalized,
*(entry for entry in state["pinned"] if entry["route"] != normalized["route"]),
][: self.pinned_limit]
self._write(connection, login, state)
return state
def unpin(self, login: str, route: str) -> dict:
login = self._login(login)
if not isinstance(route, str) or not route:
raise ValueError("recent work route is invalid")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT items FROM recent_work WHERE login = ?", (login,)
).fetchone()
state, _legacy = self._state(row, login)
state["pinned"] = [entry for entry in state["pinned"] if entry["route"] != route]
self._write(connection, login, state)
return state

View File

@ -76,7 +76,6 @@ 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
@ -94,10 +93,6 @@ 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(
@ -107,10 +102,6 @@ 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"
@ -135,7 +126,6 @@ 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
@ -155,8 +145,7 @@ class SecurityEventStore:
) in rows:
connection.execute(
"INSERT INTO security_events_encrypted "
"(id, payload, principal_id, created_at, status, operation_id) "
"VALUES (?, ?, NULL, ?, ?, ?)",
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
(
event_id,
payload
@ -206,7 +195,6 @@ class SecurityEventStore:
self,
kind: str,
*,
principal_id: int,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
@ -215,9 +203,8 @@ class SecurityEventStore:
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(principal_id, created_at, status) "
"VALUES (?, ?, 'completed')",
(principal_id, now),
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
(now,),
)
event_id = cursor.lastrowid
payload = self._seal_event(
@ -237,7 +224,6 @@ class SecurityEventStore:
self,
kind: str,
*,
principal_id: int,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
@ -247,9 +233,9 @@ class SecurityEventStore:
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
"VALUES (?, ?, 'pending', ?)",
(principal_id, now, operation_id),
"INSERT INTO security_events(created_at, status, operation_id) "
"VALUES (?, 'pending', ?)",
(now, operation_id),
)
event_id = cursor.lastrowid
connection.execute(
@ -296,14 +282,12 @@ class SecurityEventStore:
"Security activity is temporarily unavailable"
) from exc
def list(
self, *, principal_id: int, limit: int = 50, cursor: int | None = None
) -> SecurityEventPage:
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
bounded_limit = min(100, max(1, limit))
parameters: list[int] = [principal_id]
where = "WHERE principal_id = ?"
parameters: list[int] = []
where = ""
if cursor is not None:
where += " AND id < ?"
where = "WHERE id < ?"
parameters.append(cursor)
parameters.append(bounded_limit + 1)
try:

View File

@ -259,18 +259,16 @@ 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, *, principal_id: int
) -> list[ActiveDevice]:
def list_active(self, current_session_id: str) -> 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 > ? AND principal_id = ? "
"FROM active_sessions WHERE expires_at > ? "
"ORDER BY expires_at DESC, created_at DESC",
(now, principal_id),
(now,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc

View File

@ -2,8 +2,6 @@
import os
import pytest
os.environ.setdefault(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
@ -12,15 +10,4 @@ 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)
)

View File

@ -1,231 +0,0 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged adaptive Queues journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 375, "height": 667},
{"width": 430, "height": 932},
])
def test_adaptive_queues_put_truthful_next_action_above_the_fold(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-launcher.js")
page.evaluate("""() => {
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.adaptiveQueueLauncher = createMobileQueueLauncher({
getCounts:() => ({delivery:1, gate:2, today:3, attentionUnavailable:true}),
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.adaptiveQueueLauncher.renderPresentation();
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("Recover Delivery (1)")
bounds = next_action.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert bounds["y"] + bounds["height"] <= viewport["height"]
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["delivery", "gate", "attention"]
expect(page.locator('[data-mobile-queue="attention"]')).to_have_attribute("data-unavailable", "true")
assert page.locator("#mobile-queue-planning-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["today", "tomorrow", "week"]
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()

View File

@ -1,290 +0,0 @@
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Human Gates journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
current = {"gate": "g1"}
evidence_url = {"value": "/evidence/manifest"}
list_requests: list[str] = []
decision_requests: list[dict] = []
browser_errors: list[str] = []
def gate(gate_id: str) -> dict:
return {
"id": gate_id,
"title": "First candidate" if gate_id == "g1" else "Fresh candidate",
"project": "stackchain/stackchain-dashboard",
"candidate_hash": "a1" if gate_id == "g1" else "b2",
"revision": 1,
"priority": 5,
"checks": [],
"artifacts": [{"name": "Signed manifest", "url": evidence_url["value"]}],
"links": [],
"provenance": {},
"history": [],
}
try:
with release_server(
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
) as origin, sync_playwright() as playwright:
evidence_url["value"] = origin + "/evidence/manifest"
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
def human_gates_route(route):
path = route.request.url.split("?", 1)[0]
if route.request.method == "POST":
decision_requests.append({
"path": path,
"key": route.request.headers.get("idempotency-key"),
"body": route.request.post_data_json,
})
if len(decision_requests) == 1:
route.abort("failed")
return
payload = {"receipt_id": "receipt-1", "state": "released"}
elif path.endswith("/api/v1/human-gates"):
list_requests.append(current["gate"])
payload = {"pending_count": 1, "items": [gate(current["gate"])]}
else:
payload = gate(path.rsplit("/", 1)[-1])
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
page.route("**/api/v1/human-gates**", human_gates_route)
page.route("**/api/v1/human-gates/**", human_gates_route)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Human Gates release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
tray = page.locator(".human-gate-decision-tray")
expect(tray).to_be_visible()
expect(page.locator("[data-gate-readiness]")).to_have_text(
"0 of 3 confirmations complete"
)
tray_bounds = tray.bounding_box()
assert tray_bounds
assert tray_bounds["y"] + tray_bounds["height"] <= height + 1
for action in ("hold", "release"):
bounds = page.locator(f'[data-gate-decision="{action}"]').bounding_box()
assert bounds and bounds["height"] >= 44
page.locator('[data-gate-decision="release"]').click()
expect(page.locator("[data-gate-error]")).to_contain_text(
"Complete the release checklist"
)
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_focused()
assert decision_requests == []
page.locator('[data-gate-checklist="exact_hash"]').check()
page.locator('[data-gate-checklist="artifacts_reviewed"]').check()
page.locator('[data-gate-checklist="provenance_reviewed"]').check()
expect(page.locator("[data-gate-readiness]")).to_have_text(
"3 of 3 confirmations complete"
)
page.locator("[data-gate-reason]").fill("Awaiting final approval")
with page.expect_popup() as popup_info:
page.get_by_role("link", name="Signed manifest").click()
popup = popup_info.value
popup.wait_for_load_state("domcontentloaded")
assert not page.url.endswith("/evidence/manifest")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("First candidate")
popup.close()
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.wait_for_load_state("networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
expect(page.locator('[data-gate-checklist="artifacts_reviewed"]')).to_be_checked()
expect(page.locator('[data-gate-checklist="provenance_reviewed"]')).to_be_checked()
expect(page.locator("[data-gate-reason]")).to_have_value("Awaiting final approval")
progress_key = page.evaluate(
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-review.v1:'))"
)
assert progress_key and progress_key.endswith(":g1:1")
assert page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
page.locator('[data-gate-decision="release"]').click()
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
expect(page.locator("[data-gate-recover]")).to_have_count(1)
decision_key = page.evaluate(
"Object.keys(localStorage).find(key => key.startsWith('stackchain.human-gate-decision.v1:'))"
)
assert decision_key
assert len(decision_requests) == 1
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.wait_for_load_state("networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
recover = page.locator("[data-gate-recover]")
expect(recover).to_be_visible()
recover_height = recover.evaluate("element => element.getBoundingClientRect().height")
assert recover_height >= 44
recover.click()
expect(page.locator("#human-gates-status")).to_contain_text("Decision saved")
expect(page.locator(".human-gate-decision-tray")).to_have_count(0)
assert len(decision_requests) == 2
assert decision_requests[0] == decision_requests[1]
assert not page.evaluate("key => localStorage.getItem(key) !== null", decision_key)
assert not page.evaluate("key => localStorage.getItem(key) !== null", progress_key)
page.evaluate("document.querySelector('#close-human-gates').click()")
before_reopen = len(list_requests)
current["gate"] = "g2"
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gate-detail")).to_contain_text("Fresh candidate")
expect(page.locator("#human-gates-list")).to_contain_text("Fresh candidate")
assert len(list_requests) == before_reopen + 1
assert page.evaluate("window.innerWidth") == width
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
bounds = page.locator('[data-human-gate-id="g2"]').bounding_box()
assert bounds and bounds["height"] >= 44
assert not browser_errors
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
browser_errors: list[str] = []
history_requests: list[str] = []
held = {
"id": "held", "title": "Held candidate", "project": "stackchain/stackchain-dashboard",
"candidate_hash": "bbb222", "state": "held", "revision": 2, "priority": 5,
"created_at": 100, "updated_at": 200, "reason": "Needs mobile evidence",
"override_reason": "", "checklist": {}, "receipt_id": "receipt-2",
"checks": [], "artifacts": [], "links": [], "provenance": {},
"history": [{"action": "held", "at": 200, "receipt_id": "receipt-2"}],
}
released = {
**held, "id": "released", "title": "Released candidate", "candidate_hash": "aaa111",
"state": "released", "updated_at": 150, "reason": "", "receipt_id": "receipt-1",
}
superseded = {
**held, "id": "superseded", "title": "Superseded candidate",
"candidate_hash": "old000", "state": "superseded", "updated_at": 100,
"reason": "", "receipt_id": None,
}
try:
with release_server(
archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}"
) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": 390, "height": 844}, ignore_https_errors=True
)
page = context.new_page()
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
def gates_route(route):
history_requests.append(route.request.url)
if "state=history" in route.request.url and "cursor=older-page" in route.request.url:
payload = {"pending_count": 0, "items": [superseded], "next_cursor": None}
elif "state=history" in route.request.url:
payload = {"pending_count": 0, "items": [held, released], "next_cursor": "older-page"}
elif route.request.url.endswith("/held"):
payload = held
else:
payload = {"pending_count": 0, "items": []}
route.fulfill(status=200, content_type="application/json", body=json.dumps(payload))
page.route("**/api/v1/human-gates**", gates_route)
page.route("**/api/v1/human-gates/**", gates_route)
page.route("**/api/v1/human-gate-receipts/receipt-2", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps({
"receipt_id": "receipt-2", "gate_id": "held", "candidate_hash": "bbb222",
"state": "held", "decided_at": 200, "reason": "Needs mobile evidence",
"override_reason": "", "checklist": {}, "unmet_required_checks": [],
})
))
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Human Gate history phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.wait_for_load_state("networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
cached_before = page.evaluate(
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
)
page.locator("#human-gates-history").click()
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
expect(page.locator('[data-human-gate-history-id="held"]')).to_contain_text("Held")
expect(page.locator('[data-human-gate-history-id="released"]')).to_contain_text("Released")
page.locator('[data-human-gate-history-id="held"]').click()
expect(page.locator("#human-gate-detail")).to_contain_text("Needs mobile evidence")
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
expect(page.locator("#human-gate-detail [data-gate-decision]")).to_have_count(0)
more = page.get_by_role("button", name="Load older decisions")
more_bounds = more.bounding_box()
assert more_bounds and more_bounds["height"] >= 44
more.click()
expect(page.locator('[data-human-gate-history-id="superseded"]')).to_contain_text("Superseded")
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
assert page.evaluate(
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
) == cached_before
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
for selector in ("#human-gates-pending", "#human-gates-history"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert any("state=history&limit=20" in url for url in history_requests)
assert any("cursor=older-page" in url for url in history_requests)
assert not browser_errors
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)

View File

@ -28,7 +28,6 @@ def test_authored_pull_queue_is_phone_usable_and_opens_the_existing_pull_route(v
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.add_script_tag(path=FRONTEND / "pull-sheet.js")
page.evaluate("document.querySelector('#mobile-queue-sheet').showModal()")
page.locator(".mobile-queue-all summary").click()
row = page.locator('[data-mobile-queue="authored"]')
expect(row).to_have_count(1)

View File

@ -41,10 +41,6 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
page.locator('[data-mobile-task="work"]').click()
@ -60,7 +56,6 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
expect(sheet).to_be_hidden()
expect(page.locator("#find-work-sheet")).to_be_visible()
page.locator("#close-find-work").click()
expect(page.locator("#find-work-sheet")).to_be_hidden()
context.set_offline(True)
page.locator('[data-mobile-task="work"]').click()
@ -71,9 +66,8 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
expect(page.locator("#mobile-first-task-create")).to_be_focused()
page.locator("#close-mobile-first-task").click()
expect(sheet).to_be_hidden()
context.set_offline(False)
probe_result = page.evaluate(
page.evaluate(
"""() => {
document.querySelector('[data-mobile-today-hud]').hidden = false;
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
@ -84,54 +78,17 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
coach: isolatedCoach,
});
return {
result: window.firstTaskOutcomeProbe.refresh(),
state: localStorage.getItem('stackchain.first-task.v1:timmy'),
mobile: matchMedia('(max-width: 600px)').matches,
};
return window.firstTaskOutcomeProbe.refresh();
}"""
)
assert probe_result == {"result": "coaching", "state": "coaching", "mobile": True}
coach = page.locator("[data-mobile-first-task-coach]")
try:
expect(coach).to_be_visible()
except AssertionError as error:
raise AssertionError(page.evaluate("""() => {
const coach = document.querySelector('[data-mobile-first-task-coach]');
const hud = coach.closest('[data-mobile-today-hud]');
return {
coachHidden: coach.hidden,
coachDisplay: getComputedStyle(coach).display,
hudHidden: hud.hidden,
hudOverlay: hud.getAttribute('data-overlay-hidden'),
hudDisplay: getComputedStyle(hud).display,
open: Array.from(document.querySelectorAll('dialog, .open'))
.filter(element => element.open || element.classList.contains('open'))
.map(element => element.id || element.className),
};
}""")) from error
expect(coach).to_be_visible()
expect(coach).to_contain_text("Complete your first task")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
completion = page.evaluate("""() => {
const completed = window.firstTaskOutcomeProbe.completeOutcome();
return {
completed,
status: document.querySelector('#today-sync-status').textContent,
pending: Object.keys(localStorage).filter(key =>
key.startsWith('stackchain.today-sync.v1.timmy.operation.')),
};
}""")
assert completion["completed"] is True
assert completion["pending"]
assert "sync pending" in completion["status"]
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
expect(coach).to_be_hidden()
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
remote_activation = page.evaluate("""async () => {
const response = await fetch('api/v1/today');
return (await response.json()).first_task_state;
}""")
assert remote_activation == "complete"
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
fresh_context = browser.new_context(
@ -143,12 +100,7 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
fresh_page.locator("#submit-sign-in").click()
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
fresh_page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
fresh_page.wait_for_load_state("networkidle")
assert fresh_page.evaluate(
"localStorage.getItem('stackchain.first-task.v1:timmy')"
) == "complete"
@ -176,7 +128,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
browser_errors: list[str] = []
failed_responses: list[str] = []
workspace_requests: list[str] = []
optional_workspace_requests: list[str] = []
launch_transfer_events: list[str] = []
live_requests: list[str] = []
@ -220,12 +171,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
)
if "feature-work-core-" in request.url else None,
)
page.on(
"request",
lambda request: optional_workspace_requests.append(request.url)
if "feature-today-timer-" in request.url or "feature-planning-" in request.url
else None,
)
page.on(
"requestfinished",
lambda request: launch_transfer_events.append("core-finished")
@ -239,7 +184,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
)
page.goto(origin + "/", wait_until="networkidle")
assert optional_workspace_requests == []
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
live_requests.clear()
@ -250,24 +194,9 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
), launch_transfer_events
expect(page.locator("#my-work-status")).to_contain_text("2")
assert optional_workspace_requests == []
revisionless_live_requests = [url for url in live_requests if "?" not in url]
assert len(revisionless_live_requests) == 1, live_requests
initial_live_requests = len(live_requests)
page.locator("#work-settings-toggle").click()
tomorrow = page.locator("#plan-tomorrow")
expect(tomorrow).to_be_visible()
assert len(optional_workspace_requests) == 2, optional_workspace_requests
tomorrow_bounds = tomorrow.bounding_box()
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
tomorrow.click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
expect(page.locator("#save-and-start-today")).to_be_hidden()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-plan-today").click()
page.evaluate(
"""
() => {
@ -297,8 +226,19 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#work-settings-toggle").click()
tomorrow = page.locator("#plan-tomorrow")
expect(tomorrow).to_be_visible()
tomorrow_bounds = tomorrow.bounding_box()
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
tomorrow.click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
expect(page.locator("#save-and-start-today")).to_be_hidden()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="queues"]').click()
page.locator(".mobile-queue-all summary").click()
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
expect(delivery_queue).to_be_visible()
expect(delivery_queue).to_contain_text("Delivery")
@ -405,10 +345,7 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(
viewport={"width": 390, "height": 844}, service_workers="block"
)
page = context.new_page()
page = browser.new_page(viewport={"width": 390, "height": 844})
page.goto(origin + "/", wait_until="networkidle")
def interrupt_once(route):
@ -423,7 +360,6 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="find"]').click()
page.wait_for_timeout(1500)
resources = page.evaluate(
@ -469,10 +405,6 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
page.evaluate(
"""
@ -575,10 +507,6 @@ def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="agenda"]').click()

View File

@ -114,13 +114,6 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
process.communicate()
def hydrate_workspace(page) -> None:
page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
def indexed_issue_records(page: Page) -> list[dict]:
return page.evaluate(
"""async () => {
@ -282,7 +275,6 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert fake.created_issues == []
page.reload(wait_until="domcontentloaded")
hydrate_workspace(page)
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
durable_after_reload = indexed_issue_records(page)
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
@ -314,13 +306,9 @@ 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. 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.
# after the pre-reconnect clear; retain every non-offline browser error.
browser_errors[:] = [
error for error in browser_errors
if "ERR_INTERNET_DISCONNECTED" not in error
and not error.startswith("context failed TypeError: Failed to fetch")
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
]
for _ in range(40):
@ -330,7 +318,6 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
page.wait_for_timeout(100)
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.evaluate("window.dispatchEvent(new Event('online'))")
for _ in range(40):
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))

View File

@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
@ -60,7 +60,6 @@ def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work
})"""
)
page.reload(wait_until="networkidle")
hydrate_workspace(page)
stored = page.evaluate(
"""() => new Promise((resolve,reject)=>{
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);

View File

@ -1,107 +0,0 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-recent-work.js")
page.evaluate("""() => {
const values = new Map();
const storage = {
getItem:key => values.get(key) || null,
setItem:(key, value) => values.set(key, value),
};
window.opened = [];
window.recentWork = createMobileRecentWork({
storage, getLogin:() => 'timmy', document,
section:document.querySelector('#mobile-recent-work'),
list:document.querySelector('#mobile-recent-work-list'),
pinnedSection:document.querySelector('#mobile-pinned-work'),
pinnedList:document.querySelector('#mobile-pinned-work-list'),
pinnedToggle:document.querySelector('#mobile-pinned-work-toggle'),
detailPins:document.querySelectorAll('[data-current-work-pin]'),
status:document.querySelector('#mobile-recent-work-status'),
openRoute:route => window.opened.push(route),
});
for (let number=1; number<=20; number += 1) {
const item = {
kind:'issue', repository:'stackchain/stackchain-dashboard', number,
title:'Pinned mobile work ' + number,
};
window.recentWork.record(item);
window.recentWork.pin(item);
}
document.querySelector('#mobile-queue-sheet').showModal();
}""")
expect(page.locator("#mobile-pinned-work")).to_be_visible()
expect(page.locator("#mobile-recent-work")).to_be_hidden()
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
toggle = page.locator("#mobile-pinned-work-toggle")
open_button = page.get_by_role(
"button", name="Open Pinned mobile work 20, issue stackchain/stackchain-dashboard #20"
).first
pin_button = page.get_by_role(
"button", name="Unpin Pinned mobile work 20"
)
for control in (open_button, pin_button, toggle):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
toggle.click()
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(20)
expect(toggle).to_have_text("Show fewer")
assert toggle.get_attribute("aria-expanded") == "true"
toggle.click()
expect(page.locator("#mobile-pinned-work-list .mobile-recent-work-row")).to_have_count(3)
open_button.focus()
open_button.press("Enter")
assert page.evaluate("window.opened") == [
"#/my-work/issue/stackchain/stackchain-dashboard/20"
]
page.evaluate("""() => {
document.querySelector('#mobile-queue-sheet').close();
document.querySelector('#issue-sheet').classList.add('open');
window.recentWork.setCurrent({
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1489,
title:'Pin the current item from mobile detail',
});
}""")
detail_pin = page.get_by_role(
"button", name="Pin Pin the current item from mobile detail"
)
expect(detail_pin).to_be_visible()
bounds = detail_pin.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
detail_pin.focus()
detail_pin.press("Enter")
expect(detail_pin).to_have_text("Pinned")
expect(detail_pin).to_have_attribute("aria-pressed", "true")
assert page.evaluate("window.recentWork.pinned()[0].number") == 1489
browser.close()

View File

@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def open_today_action(page, selector: str):
@ -52,7 +52,6 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
try:
expect(page.locator("#my-work-status")).to_contain_text("2")
@ -270,7 +269,6 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
localStorage.setItem(prefix + 'broken', '{not-json');
}""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
expect(page.locator("#today-sync-status")).to_have_text(
"Today queue recovered · discarded 1 unreadable device record."
@ -278,16 +276,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
assert page.evaluate("""() => !Object.keys(localStorage).some(
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
)""")
try:
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
except AssertionError as error:
raise AssertionError(page.evaluate("""() => ({
login: document.querySelector('#current-user')?.textContent,
timers: Object.fromEntries(Object.keys(localStorage)
.filter(key => key.startsWith('stackchain.today-timer.v1.'))
.map(key => [key, JSON.parse(localStorage.getItem(key))])),
status: document.querySelector('#today-break-status').outerHTML,
})""")) from error
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
@ -388,7 +377,6 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
@ -428,7 +416,6 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
queue_pause = page.locator("#mobile-queue-sheet [data-today-detour]")
expect(queue_pause).to_be_visible()
expect(queue_pause).to_contain_text("Today paused · Ship mobile capture")
page.locator(".mobile-queue-all summary").click()
page.locator('[data-mobile-queue="later"]').click()
persistent_pause = page.locator("#my-work > [data-today-detour]")
expect(persistent_pause).to_be_visible()
@ -458,7 +445,6 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
}""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
expect(restored_pause).to_be_visible()
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
@ -525,7 +511,6 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
@ -576,7 +561,6 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
}));
}""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()

View File

@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@ -50,7 +50,6 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.evaluate(
"""

View File

@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@ -47,7 +47,6 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.evaluate(
"""

View File

@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@ -71,7 +71,6 @@ def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
def stage_phone_plan():
page.evaluate(

View File

@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
@ -36,7 +36,6 @@ def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path)
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
]))""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
dialog = page.locator("#today-handoff-dialog")
expect(dialog).to_be_visible()

View File

@ -1,65 +0,0 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("progressive mobile dock checks run only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [{"width": 320, "height": 568}, {"width": 390, "height": 844}])
def test_mobile_work_and_queues_are_usable_before_optional_hydration(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
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 / "my-work.js")
page.add_script_tag(path=FRONTEND / "context-poller.js")
page.evaluate(
"""() => {
window.fetch = async () => ({
ok:true, headers:{get:()=>null}, json:async()=>({
context:{
user:{login:'timmy'},
issues:[{
number:1427,title:'Progressive mobile dock',repository:'stackchain/stackchain-dashboard',
assignees:['timmy'],work_reasons:['created_by_me'],url:'https://forge.example/issues/1427',
}],
pull_requests:[],
},
events:[],notifications:[],
}),
});
}"""
)
page.add_script_tag(path=FRONTEND / "progressive-my-work.js")
expect(page.locator("#my-work-status")).to_contain_text("1 assigned work item")
page.add_script_tag(path=FRONTEND / "progressive-mobile-dock.js")
page.locator('[data-mobile-task="queues"]').click()
expect(page.locator("#mobile-queue-sheet")).to_be_visible()
expect(page.locator('[data-mobile-queue="filed"] [data-mobile-queue-count]')).to_have_text("1")
page.locator(".mobile-queue-all summary").click()
expect(page.locator('[data-mobile-queue="delivery"]')).to_contain_text("Still loading")
next_action = page.locator("#mobile-queue-next-action")
bounds = next_action.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator('[data-mobile-queue="filed"]').click()
expect(page.locator("#progressive-work-detail")).to_be_visible()
expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock")
page.locator("#close-progressive-work-detail").click()
page.locator('[data-mobile-task="work"]').click()
expect(page.locator("#progressive-work-detail")).to_be_visible()
expect(page.locator("#progressive-work-detail-title")).to_have_text("Progressive mobile dock")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()

View File

@ -6,39 +6,6 @@ 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)
@ -47,8 +14,6 @@ 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",
@ -63,7 +28,6 @@ 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",
@ -79,7 +43,7 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
)
with pytest.raises(SessionStoreError):
passkeys.revoke_all_access(principal_id=42)
passkeys.revoke_all_access()
with sqlite3.connect(database) as connection:
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
@ -99,8 +63,6 @@ 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",
@ -114,7 +76,6 @@ 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(
@ -124,7 +85,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
)
with pytest.raises(SessionStoreError):
passkeys.revoke_device_access("phone-management-id", principal_id=42)
passkeys.revoke_device_access("phone-management-id")
with sqlite3.connect(database) as connection:
assert connection.execute(
@ -134,69 +95,3 @@ 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",),
]

View File

@ -6,8 +6,6 @@ from src import main
@pytest.mark.anyio
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
async def user():
return {"id": 1, "login": "timmy"}
@ -17,17 +15,13 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
async def empty_events(user_data=None):
return []
async def empty_live_snapshot(**_revisions):
return main.JSONResponse({})
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
monkeypatch.setattr(main, "activity_events", empty_events)
monkeypatch.setattr(main, "_live_snapshot_response", empty_live_snapshot)
transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
response = await client.get(path)

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-v150" in worker
assert "stackchain-dashboard-shell-v139" in worker

View File

@ -117,32 +117,6 @@ const poller = createContextPoller({{
}
def test_context_poller_can_force_a_full_identity_refresh_after_adoption():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const requested = [];
const seed = {{
context:{{user:{{login:'timmy'}}}}, events:[], notifications:[],
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2'}},
}};
const poller = createContextPoller({{
fetchContext: revisions => {{
requested.push({{...revisions}});
return Promise.resolve({{...seed, freshness:{{sections:{{context:{{degraded:false}}}}}}}});
}},
onSnapshot: () => {{}}, onError: error => {{ throw error; }},
setTimer: () => 1, clearTimer: () => {{}},
}});
poller.adopt(seed);
(async () => {{
await poller.refresh({{force:true, full:true}});
process.stdout.write(JSON.stringify({{requested}}));
}})();
"""
assert run_node(script) == {"requested": [{}]}
def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
@ -226,43 +200,6 @@ def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load()
assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
assert "if (!adoptedProgressiveSnapshot) await load();" in source
assert "contextPoller.refresh({ force:true, full:true })" in source
assert "fetchReviewJson('api/v1/background-identity')" in source
assert "initialAccountRecovery = timerView.restore(todaySync.flush());" in source
def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: null, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 5,
sections: {{
context: {{ degraded: true, retry_in_seconds: 5, age_seconds: 12 }},
events: {{ degraded: false, age_seconds: 0 }},
notifications: {{ degraded: false, age_seconds: 0 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (_callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
setDeadlineTimer: () => 1,
clearDeadlineTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.refresh();
process.stdout.write(JSON.stringify({{delays}}));
}})();
"""
assert run_node(script) == {"delays": [5000]}
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():

View File

@ -18,10 +18,6 @@ def test_release_rollback_is_a_supported_step_up_action():
assert "prepare_release_rollback" in get_args(main.StepUpAction)
def test_ci_job_retry_is_a_supported_step_up_action():
assert "retry_ci_job" in get_args(main.StepUpAction)
@pytest.fixture
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@ -129,158 +125,6 @@ async def fresh_grant(client, action: str, target: str) -> str:
return response.json()["grant"]
@pytest.mark.anyio
async def test_ci_job_reruns_require_exact_one_time_authorization_and_audit_both_flows(
access_control, monkeypatch
):
lifecycle = []
class Journal:
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, principal_id, target):
assert principal_id == 42
operation_id = f"operation-{len(lifecycle)}"
lifecycle.append(("reserve", kind, target, operation_id))
return operation_id
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def capabilities(repository, number):
return {"authored": True, "assigned": False}
async def retry_pull(repository, number, head_sha, run_id, job_index):
lifecycle.append(("retry-pull", repository, number, head_sha, run_id, job_index))
return {"status": "queued"}
async def release_access(repository, number, commit_sha):
return True
async def retry_release(repository, commit_sha, run_id, job_index):
lifecycle.append(("retry-release", repository, commit_sha, run_id, job_index))
return {"status": "queued"}
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry_pull)
monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access)
monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry_release)
transport = httpx.ASGITransport(app=main.app)
pull_path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
release_path = (
"/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234"
"/checks/91/jobs/3/retry"
)
pull_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
release_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
assert signed_in.status_code == 200
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
missing = await client.post(
pull_path, json={"expected_head_sha": "abc1234"}, headers=headers
)
wrong_grant = await fresh_grant(
client, "retry_ci_job", "stackchain/api#7@different:actions/91/jobs/3"
)
mismatched = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": wrong_grant},
)
pull_grant = await fresh_grant(client, "retry_ci_job", pull_target)
retried_pull = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": pull_grant},
)
replayed = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": pull_grant},
)
release_grant = await fresh_grant(client, "retry_ci_job", release_target)
retried_release = await client.post(
release_path,
headers={**headers, "X-Step-Up-Grant": release_grant},
)
assert missing.status_code == 428
assert missing.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "retry_ci_job",
"target": pull_target,
}
assert mismatched.status_code == 428
assert retried_pull.status_code == 202
assert replayed.status_code == 428
assert retried_release.status_code == 202
assert lifecycle == [
("reserve", "ci_job_retried", pull_target, "operation-0"),
("retry-pull", "stackchain/api", 7, "abc1234", 91, 3),
("finalize", "operation-0"),
("reserve", "ci_job_retried", release_target, "operation-3"),
("retry-release", "stackchain/api", "abc1234", 91, 3),
("finalize", "operation-3"),
]
@pytest.mark.anyio
async def test_ci_job_retry_fails_closed_when_security_activity_is_unavailable(
access_control, monkeypatch
):
retry_calls = []
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
from src.security_event_store import SecurityEventStoreError
raise SecurityEventStoreError("unavailable")
async def retry(*args):
retry_calls.append(args)
return {"status": "queued"}
transport = httpx.ASGITransport(app=main.app)
path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
target = "stackchain/api#7@abc1234:actions/91/jobs/3"
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
grant = await fresh_grant(client, "retry_ci_job", target)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry)
response = await client.post(
path,
json={"expected_head_sha": "abc1234"},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
assert response.status_code == 503
assert response.json() == {
"error": "Security activity is temporarily unavailable. No job was retried."
}
assert retry_calls == []
@pytest.mark.anyio
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
access_control, monkeypatch
@ -291,8 +135,7 @@ async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, principal_id, target):
assert principal_id == 42
def reserve(self, kind, *, target):
lifecycle.append(("reserve", kind, target))
return "cleanup-operation"
@ -487,56 +330,6 @@ 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
@ -556,7 +349,6 @@ 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:
@ -572,7 +364,7 @@ async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
},
)
events = main._security_event_store().list(principal_id=42, limit=10).events
events = main._security_event_store().list(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"]
@ -599,7 +391,6 @@ 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:
@ -627,7 +418,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
headers=headers,
)
events = main._security_event_store().list(principal_id=42, limit=10).events
events = main._security_event_store().list(limit=10).events
assert options.status_code == 200
assert denied.status_code == 401
assert "grant" not in denied.json()
@ -647,7 +438,6 @@ 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))
@ -680,7 +470,6 @@ 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))
@ -731,7 +520,6 @@ 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()
@ -925,7 +713,6 @@ 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"]
@ -996,7 +783,6 @@ 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:
@ -1077,7 +863,6 @@ 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"]
@ -1122,7 +907,6 @@ 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(
@ -2094,7 +1878,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, *, principal_id):
def revoke_all_access(self):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main, "_passkey_store", lambda: BrokenStore())
@ -2131,7 +1915,6 @@ 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:

View File

@ -1,19 +0,0 @@
from src.disk_capacity import assess_disk_capacity, read_disk_capacity
def test_usage_at_incident_threshold_requires_action():
status = assess_disk_capacity(total_bytes=100, available_bytes=15)
assert status == {
"usage_percent": 85.0,
"threshold_percent": 85.0,
"incident": True,
}
def test_read_disk_capacity_assesses_a_real_filesystem(tmp_path):
status = read_disk_capacity(tmp_path)
assert 0 <= status["usage_percent"] <= 100
assert status["threshold_percent"] == 85.0
assert status["incident"] is (status["usage_percent"] >= 85.0)

View File

@ -275,7 +275,7 @@ const feature=createFollowing({{
]
def test_following_opens_explicitly_and_becomes_work_recommendation():
def test_following_opens_explicitly_but_never_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": "following", "count": 7, "label": "Review Following (7)"},
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
"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-v150" in service_worker
assert "stackchain-dashboard-shell-v139" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -33,15 +33,6 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
initial_mobile_javascript = (
len(first.runtime_gzip_bytes)
+ len(first.feature_bundles["work-core"].runtime_gzip_bytes)
)
assert initial_mobile_javascript <= 60 * 1024
assert (
len(first.feature_bundles["today-timer"].runtime_gzip_bytes)
+ len(first.feature_bundles["planning"].runtime_gzip_bytes)
) > initial_mobile_javascript
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
@ -109,13 +100,9 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
demand_loaded = {"today-timer", "planning"}
for name, bundle in first.feature_bundles.items():
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
if name in demand_loaded:
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" in optional_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)

View File

@ -1,346 +0,0 @@
import sqlite3
import httpx
import pytest
from src import main
from src.human_gate_store import HumanGateStore
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
CANDIDATE = {
"source": "release-bot", "project": "stackchain/dashboard", "candidate_hash": "abc123",
"title": "Release candidate", "priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/manifest"}],
"links": [{"label": "pull", "url": "https://forge.example/pulls/1"}],
"checks": [{"name": "unit", "state": "success", "required": True}],
"score": {"value": 98, "provenance": "eval/v1"},
"provenance": {"run": "9"},
}
CHECKLIST = {"exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": True}
@pytest.fixture
def gate_api(monkeypatch, tmp_path):
ticks = iter(range(100, 120))
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: next(ticks))
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
async def identity():
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", identity)
return store
@pytest.mark.anyio
async def test_intake_list_and_detail_are_account_bound_and_no_store(gate_api):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post("/api/v1/human-gates/intake", json=CANDIDATE, headers={"Idempotency-Key": "run-9"})
repeated = await client.post("/api/v1/human-gates/intake", json=CANDIDATE, headers={"Idempotency-Key": "run-9"})
listing = await client.get("/api/v1/human-gates")
detail = await client.get(f"/api/v1/human-gates/{created.json()['id']}")
assert created.status_code == 201
assert repeated.status_code == 200
assert repeated.json()["id"] == created.json()["id"]
assert listing.json()["pending_count"] == 1
assert detail.json()["candidate_hash"] == "abc123"
assert detail.json()["history"][0]["action"] == "intake"
assert all(response.headers["cache-control"] == "no-store" for response in (created, repeated, listing, detail))
@pytest.mark.anyio
async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-9")
transport = httpx.ASGITransport(app=main.app)
payload = {"expected_revision": gate["revision"], "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
decided = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-9"})
repeated = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-9"})
receipt = await client.get(f"/api/v1/human-gate-receipts/{decided.json()['receipt_id']}")
stale = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-10"})
assert decided.status_code == 201
assert repeated.status_code == 200
assert receipt.json() == decided.json()
assert stale.status_code == 409
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")
authorization_calls = []
async def require_step_up(request, grant, *, action, target):
authorization_calls.append({"grant": grant, "action": action, "target": target})
if grant != "one-time-grant":
raise main.HTTPException(
status_code=428,
detail={
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": action,
"target": target,
},
)
monkeypatch.setattr(main, "_require_step_up", require_step_up)
payload = {
"expected_revision": gate["revision"],
"decision": "hold",
"reason": "Needs another browser run",
"override_reason": "",
"checklist": CHECKLIST,
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
rejected = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json=payload,
headers={"Idempotency-Key": "decision-authorized"},
)
pending = await client.get(f"/api/v1/human-gates/{gate['id']}")
accepted = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json=payload,
headers={
"Idempotency-Key": "decision-authorized",
"X-Step-Up-Grant": "one-time-grant",
},
)
assert rejected.status_code == 428
assert pending.json()["state"] == "pending"
assert accepted.status_code == 201
assert authorization_calls == [
{"grant": None, "action": "decide_human_gate", "target": gate["id"]},
{"grant": "one-time-grant", "action": "decide_human_gate", "target": gate["id"]},
]
@pytest.mark.anyio
async def test_successful_decision_records_one_completed_privacy_safe_security_event(
monkeypatch, gate_api, tmp_path
):
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-audited")
journal_path = tmp_path / "security.sqlite3"
journal = SecurityEventStore(journal_path, clock=lambda: 200)
monkeypatch.setattr(main, "_security_event_store", lambda: journal)
private_reason = "Private launch context must not enter the security journal"
payload = {
"expected_revision": gate["revision"],
"decision": "hold",
"reason": private_reason,
"override_reason": "",
"checklist": CHECKLIST,
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
decided = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json=payload,
headers={"Idempotency-Key": "decision-audited"},
)
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}
for event in events
] == [
{
"kind": "human_gate_decision",
"method": "hold",
"target": gate["id"],
"status": "completed",
}
]
assert private_reason.encode() not in journal_path.read_bytes()
@pytest.mark.anyio
async def test_decision_fails_closed_when_security_event_cannot_be_reserved(
monkeypatch, gate_api
):
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-no-journal")
class UnavailableJournal:
def reserve(self, *args, **kwargs):
raise SecurityEventStoreError("private journal path")
monkeypatch.setattr(main, "_security_event_store", UnavailableJournal)
payload = {
"expected_revision": gate["revision"],
"decision": "release",
"reason": "",
"override_reason": "",
"checklist": CHECKLIST,
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
rejected = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json=payload,
headers={"Idempotency-Key": "decision-no-journal"},
)
pending = await client.get(f"/api/v1/human-gates/{gate['id']}")
assert rejected.status_code == 503
assert rejected.json() == {"detail": "Security activity is temporarily unavailable"}
assert pending.json()["state"] == "pending"
@pytest.mark.anyio
async def test_rejected_decision_discards_its_pending_security_event(
monkeypatch, gate_api, tmp_path
):
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-conflict")
journal = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 200)
monkeypatch.setattr(main, "_security_event_store", lambda: journal)
payload = {
"expected_revision": gate["revision"] + 1,
"decision": "hold",
"reason": "Outdated review",
"override_reason": "",
"checklist": CHECKLIST,
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
rejected = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json=payload,
headers={"Idempotency-Key": "decision-conflict"},
)
assert rejected.status_code == 409
assert journal.list(principal_id=1).events == []
@pytest.mark.anyio
async def test_recycled_login_cannot_read_another_principal_gates(monkeypatch, tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
principal = {"id": 1, "login": "timmy"}
async def identity():
return principal.copy()
monkeypatch.setattr(main, "current_user", identity)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/human-gates/intake", json=CANDIDATE,
headers={"Idempotency-Key": "principal-1"},
)
principal["id"] = 2
listing = await client.get("/api/v1/human-gates")
assert created.status_code == 201
assert listing.json()["pending_count"] == 0
@pytest.mark.anyio
async def test_gate_store_failure_is_sanitized_no_store(monkeypatch):
def unavailable():
raise sqlite3.OperationalError("sensitive database path")
monkeypatch.setattr(main, "_human_gate_store", unavailable)
async def identity():
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", identity)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/human-gates")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"detail": "Human Gates are temporarily unavailable"}
@pytest.mark.anyio
async def test_gate_mutations_require_idempotency_key_and_validate_override(gate_api):
failing = {**CANDIDATE, "candidate_hash": "fail123", "checks": [{"name": "browser", "state": "failure", "required": True}]}
gate = gate_api.intake("1:timmy", failing, idempotency_key="run-fail")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
missing_key = await client.post("/api/v1/human-gates/intake", json=CANDIDATE)
no_override = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json={"expected_revision": 1, "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST},
headers={"Idempotency-Key": "decision-fail"},
)
assert missing_key.status_code == 422
assert no_override.status_code == 422
assert "override reason" in no_override.json()["detail"]

View File

@ -1,180 +0,0 @@
import sqlite3
import pytest
from src.human_gate_store import GateConflict, GateValidationError, HumanGateStore
def candidate(hash_value="abc123", *, priority=2, check_state="success"):
return {
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": hash_value,
"title": "Dashboard candidate",
"priority": priority,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": check_state, "required": True}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"},
}
def checklist():
return {"exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": True}
def test_intake_is_account_bound_idempotent_and_survives_restart(tmp_path):
path = tmp_path / "gates.sqlite3"
store = HumanGateStore(path, clock=lambda: 100)
first = store.intake("timmy", candidate(), idempotency_key="producer-9")
repeated = store.intake("timmy", candidate(), idempotency_key="producer-9")
restarted = HumanGateStore(path, clock=lambda: 101)
assert repeated == first
assert restarted.detail("timmy", first["id"])["candidate_hash"] == "abc123"
assert restarted.list("alex")["pending_count"] == 0
with sqlite3.connect(path) as connection:
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
def test_new_hash_supersedes_only_older_pending_candidate_without_losing_audit(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
old = store.intake("timmy", candidate("abc123"), idempotency_key="run-1")
new = store.intake("timmy", candidate("def456"), idempotency_key="run-2")
old_detail = store.detail("timmy", old["id"])
queue = store.list("timmy")
assert old_detail["state"] == "superseded"
assert old_detail["superseded_by"] == new["id"]
assert old_detail["history"][-1]["action"] == "superseded"
assert queue["pending_count"] == 1
assert queue["items"][0]["candidate_hash"] == "def456"
def test_pending_queue_orders_highest_priority_then_oldest(tmp_path):
clock = iter([100, 101, 102]).__next__
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=clock)
low = store.intake("timmy", {**candidate("a1", priority=1), "project": "p/one"}, idempotency_key="1")
oldest_high = store.intake("timmy", {**candidate("b2", priority=5), "project": "p/two"}, idempotency_key="2")
newest_high = store.intake("timmy", {**candidate("c3", priority=5), "project": "p/three"}, idempotency_key="3")
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__)
gate = store.intake("timmy", candidate(), idempotency_key="run-1")
receipt = store.decide(
"timmy", gate["id"], expected_revision=gate["revision"], decision="release",
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
)
repeated = store.decide(
"timmy", gate["id"], expected_revision=gate["revision"], decision="release",
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
)
assert repeated == receipt
assert receipt["state"] == "released"
assert receipt["candidate_hash"] == "abc123"
assert HumanGateStore(path, clock=lambda: 999).receipt("timmy", receipt["receipt_id"]) == receipt
with pytest.raises(GateConflict):
store.decide("timmy", gate["id"], expected_revision=gate["revision"], decision="hold", reason="later", override_reason="", checklist=checklist(), idempotency_key="decision-2")
def test_hold_requires_reason_and_release_requires_checklist_and_override_for_unmet_checks(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
gate = store.intake("timmy", candidate(check_state="failure"), idempotency_key="run-1")
with pytest.raises(GateValidationError, match="Hold reason"):
store.decide("timmy", gate["id"], expected_revision=1, decision="hold", reason="", override_reason="", checklist={}, idempotency_key="d1")
with pytest.raises(GateValidationError, match="checklist"):
store.decide("timmy", gate["id"], expected_revision=1, decision="release", reason="", override_reason="needed", checklist={"exact_hash": True}, idempotency_key="d2")
with pytest.raises(GateValidationError, match="override reason"):
store.decide("timmy", gate["id"], expected_revision=1, decision="release", reason="", override_reason="", checklist=checklist(), idempotency_key="d3")
receipt = store.decide("timmy", gate["id"], expected_revision=1, decision="hold", reason="Awaiting owner", override_reason="", checklist={}, idempotency_key="d4")
assert receipt["state"] == "held"
assert receipt["checklist"] == {}
def test_same_hash_update_coalesces_one_card_and_preserves_history(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
original = store.intake("timmy", candidate(check_state="pending"), idempotency_key="run-1")
updated = store.intake("timmy", candidate(check_state="success"), idempotency_key="run-2")
assert updated["id"] == original["id"]
assert updated["revision"] == 2
assert updated["checks"][0]["state"] == "success"
assert updated["history"][-1]["action"] == "updated"
assert store.list("timmy")["pending_count"] == 1
def test_updated_checks_reopen_a_released_hash_for_review(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101, 102]).__next__)
gate = store.intake("timmy", candidate(), idempotency_key="run-1")
store.decide(
"timmy", gate["id"], expected_revision=1, decision="release",
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
)
reopened = store.intake(
"timmy", candidate(check_state="failure"), idempotency_key="run-2",
)
assert reopened["state"] == "pending"
assert reopened["revision"] == 3
assert reopened["checks"][0]["state"] == "failure"
assert reopened["history"][-1]["action"] == "reopened"
assert store.list("timmy")["pending_count"] == 1
def test_same_hash_identity_facts_cannot_be_redefined(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
store.intake("timmy", candidate(), idempotency_key="run-1")
with pytest.raises(GateConflict, match="hash"):
store.intake("timmy", {**candidate(), "title": "Changed facts"}, idempotency_key="run-2")

View File

@ -1,897 +0,0 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js"
PROGRESSIVE = Path(__file__).parents[1] / "frontend" / "progressive-human-gates.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
LIVE_SNAPSHOT = Path(__file__).parents[1] / "frontend" / "progressive-live-snapshot.js"
PROGRESSIVE_MY_WORK = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
def run_node(body):
script = f"const createHumanGates=require({json.dumps(str(MODULE))});\n" + body
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
return json.loads(result.stdout)
def test_progressive_my_work_and_human_gates_share_one_cold_live_snapshot():
script = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
globalThis.buildMyWork=require({json.dumps(str(MY_WORK))});
const createProgressiveMyWork=require({json.dumps(str(PROGRESSIVE_MY_WORK))});
const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))});
let liveCalls=0, directMyWorkCalls=0, resolveLive;
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:()=>{{
liveCalls += 1;
return new Promise(resolve=>{{resolveLive=resolve;}});
}}}});
const workDocument={{
hidden:false,
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
querySelectorAll:()=>[],
}};
const work=createProgressiveMyWork({{
document:workDocument, liveSnapshot:broker,
fetchSnapshot:async()=>{{directMyWorkCalls += 1; return {{}};}},
}});
const nodes={{
'#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}},
'#human-gates-status':{{}}, '#human-gates':{{hidden:true}},
'#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}},
'#open-human-gates':{{addEventListener(){{}}}}, '#close-human-gates':{{addEventListener(){{}}}},
}};
const gates=createProgressiveHumanGates({{
document:{{querySelector:selector=>nodes[selector]||null}},
location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}},
storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true,
liveSnapshot:broker,
fetchJson:async path=>({{pending_count:1,items:[{{id:'g1',title:'Shared identity',candidate_hash:'abc',revision:1,checks:[]}}]}}),
}});
(async()=>{{
const workStart=work.start(); const gateStart=gates.start();
await Promise.resolve(); await Promise.resolve();
const callsWhilePending=liveCalls;
resolveLive({{context:{{user:{{id:7,login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[]}});
await Promise.all([workStart,gateStart]);
process.stdout.write(JSON.stringify({{
callsWhilePending,liveCalls,directMyWorkCalls,
login:work.login(),gateStarted:gates.handoff().started,
}}));
}})().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
assert json.loads(result.stdout) == {
"callsWhilePending": 1,
"liveCalls": 1,
"directMyWorkCalls": 0,
"login": "timmy",
"gateStarted": True,
}
def test_progressive_human_gates_retries_partial_snapshot_identity_without_hydration():
script = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))});
let liveCalls=0, gateCalls=0;
const responses=[
{{context:null,events:[],notifications:[]}},
{{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}},
];
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{liveCalls+=1;return responses.shift();}}}});
const nodes={{
'#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}},
'#human-gates-status':{{}}, '#human-gates':{{hidden:true}},
'#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}},
'#open-human-gates':{{addEventListener(){{}}}}, '#close-human-gates':{{addEventListener(){{}}}},
}};
const gates=createProgressiveHumanGates({{
document:{{querySelector:selector=>nodes[selector]||null}},
location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}},
storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true,
liveSnapshot:broker,
fetchJson:async()=>{{gateCalls+=1;return {{pending_count:1,items:[{{id:'g1',title:'Recovered',candidate_hash:'abc',revision:1,checks:[]}}]}};}},
}});
(async()=>{{
let firstError='';
try{{await gates.start();}}catch(error){{firstError=error.message;}}
const recovered=await gates.start();
process.stdout.write(JSON.stringify({{
firstError,recovered,liveCalls,gateCalls,started:gates.handoff().started,
}}));
}})().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
assert json.loads(result.stdout) == {
"firstError": "Authenticated account identity is unavailable.",
"recovered": True,
"liveCalls": 2,
"gateCalls": 2,
"started": True,
}
def test_queue_loads_pending_count_uses_account_cache_and_renders_inbox_zero():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let response={pending_count:1,items:[{id:'g1',title:'Candidate',candidate_hash:'abc123',priority:4,state:'pending',revision:1,checks:[]}]};
const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true}};
const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>response});
(async()=>{ await gates.load(); const first={snapshot:gates.snapshot(),count:nodes.count.textContent,html:nodes.list.innerHTML,keys:[...values.keys()]}; response={pending_count:0,items:[]}; await gates.load(); process.stdout.write(JSON.stringify({first,zero:{snapshot:gates.snapshot(),status:nodes.status.textContent,html:nodes.list.innerHTML}})); })();
""")
assert output["first"]["count"] == "1"
assert "abc123" in output["first"]["html"]
assert output["first"]["keys"] == ["stackchain.human-gates.v1:timmy"]
assert output["zero"]["snapshot"]["pending_count"] == 0
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=[];
const item={id:'g1',title:'Candidate',candidate_hash:'abc123',revision:1,checks:[]};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}}, getLogin:()=> 'timmy', isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{},detail:{}}, location:{hash:''},
onChange:(snapshot, state)=>changes.push({count:snapshot.pending_count,...state}),
fetchJson:async(path, options={})=>options.method==='POST' ? {receipt_id:'r1'} : {pending_count:1,items:[item]},
});
(async()=>{await gates.load();gates.reviewNext();await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}});process.stdout.write(JSON.stringify(changes));})();
""")
assert output == [
{"count": 1, "available": True, "authoritative": True},
{"count": 0, "available": True, "authoritative": True, "decision": True},
]
def test_queue_load_failure_distinguishes_cached_read_only_data_from_unavailable():
output = run_node(r"""
const cached={pending_count:1,items:[{id:'g1',title:'Cached',candidate_hash:'abc'}]};
const changes=[];
const make=(value,label)=>createHumanGates({
storage:{getItem:()=>value ? JSON.stringify(value) : null,setItem(){}},
getLogin:()=> 'timmy', isOnline:()=>false, nodes:{count:{},list:{},status:{},panel:{}}, location:{hash:''},
onChange:(snapshot,state)=>changes.push({label,count:snapshot.pending_count,...state}),
fetchJson:async()=>{throw new Error('network')},
});
(async()=>{await make(cached,'cached').load();try{await make(null,'empty').load()}catch(_){}process.stdout.write(JSON.stringify(changes));})();
""")
assert output == [
{"label": "cached", "count": 1, "available": True, "authoritative": False, "cached": True},
{"label": "empty", "count": 0, "available": False, "authoritative": False},
]
def test_review_next_is_a_fixed_snapshot_and_decision_and_next_advances_without_new_arrivals():
output = run_node(r"""
const calls=[]; const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true},detail:{innerHTML:''}};
const initial={pending_count:2,items:[{id:'old',title:'Old',candidate_hash:'a1',revision:1,checks:[]},{id:'next',title:'Next',candidate_hash:'b2',revision:1,checks:[]}]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:'#/my-work/human-gates'},fetchJson:async(path,options={})=>{calls.push({path,options}); if(options.method==='POST') return {receipt_id:'r1',state:'released'}; return initial;}});
(async()=>{await gates.load(); const reviewed=gates.reviewNext(); initial.items.unshift({id:'new',title:'New arrival',candidate_hash:'c3',revision:1,checks:[]}); const result=await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}}); process.stdout.write(JSON.stringify({reviewed,result,current:gates.current(),calls,hash:gates.route()}));})();
""")
assert output["reviewed"]["id"] == "old"
assert output["result"]["receipt"]["receipt_id"] == "r1"
assert output["current"]["id"] == "next"
assert output["hash"] == "#/my-work/human-gates"
post = next(call for call in output["calls"] if call["options"].get("method") == "POST")
assert post["path"] == "api/v1/human-gates/old/decision"
assert "Idempotency-Key" in post["options"]["headers"]
def test_review_loads_exact_detail_with_links_provenance_and_history():
output = run_node(r"""
const detail={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:1,checks:[{name:'unit',state:'success',required:true}],artifacts:[{name:'manifest',url:'https://forge.example/manifest'}],links:[{label:'pull',url:'https://forge.example/pull/1'}],score:{value:98,provenance:'eval/v1'},provenance:{producer:'bot',run_id:'9'},history:[{action:'intake',at:100}]};
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.endsWith('/g1')?detail:{pending_count:1,items:[detail]}});
(async()=>{await gates.load();gates.reviewNext();await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({html:nodes.detail.innerHTML}));})();
""")
assert "stackchain/dashboard" in output["html"]
assert "https://forge.example/pull/1" in output["html"]
assert output["html"].count('target="_blank"') == 2
assert output["html"].count('rel="noreferrer noopener"') == 2
assert "bot" in output["html"]
assert "intake" in output["html"]
def test_unfinished_review_restores_after_reload_for_same_account_gate_and_revision():
output = run_node(r"""
const values=new Map();
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
const item={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:4,checks:[]};
const make=()=>{
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:'',addEventListener(){}}};
return {nodes,gates:createHumanGates({
storage,getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,nodes,location:{hash:''},
fetchJson:async path=>path.endsWith('/g1')?item:{pending_count:1,items:[item]},
})};
};
(async()=>{
const first=make(); await first.gates.load(); first.gates.reviewNext();
first.gates.saveProgress({
checklist:{exact_hash:true,artifacts_reviewed:false,provenance_reviewed:true},
reason:'Need the signed manifest',override_reason:'Approved exception',
});
const reloaded=make(); await reloaded.gates.load(); reloaded.gates.reviewNext();
await new Promise(resolve=>setTimeout(resolve,0));
process.stdout.write(JSON.stringify({keys:[...values.keys()],html:reloaded.nodes.detail.innerHTML}));
})();
""")
assert "stackchain.human-gate-review.v1:7:timmy:g1:4" in output["keys"]
assert 'data-gate-checklist="exact_hash" checked' in output["html"]
assert 'data-gate-checklist="artifacts_reviewed" checked' not in output["html"]
assert 'data-gate-checklist="provenance_reviewed" checked' in output["html"]
assert "Need the signed manifest" in output["html"]
assert "Approved exception" in output["html"]
def test_review_form_changes_are_saved_without_a_decision_tap():
output = run_node(r"""
const values=new Map(), listeners={};
const inputs=[
{dataset:{gateChecklist:'exact_hash'},checked:true},
{dataset:{gateChecklist:'artifacts_reviewed'},checked:true},
{dataset:{gateChecklist:'provenance_reviewed'},checked:false},
];
const reason={value:'Waiting for mobile evidence'}, override={value:'Temporary exception'};
const detail={
innerHTML:'', addEventListener:(name,listener)=>listeners[name]=listener,
querySelectorAll:()=>inputs,
querySelector:selector=>selector==='[data-gate-reason]'?reason:override,
};
const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:2,checks:[]};
const gates=createHumanGates({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
getLogin:()=> 'timmy',getAccountKey:()=> '7:timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
fetchJson:async()=>({pending_count:1,items:[item]}),
});
(async()=>{
await gates.load(); gates.reviewNext();
listeners.input();
const saved=JSON.parse(values.get('stackchain.human-gate-review.v1:7:timmy:g1:2'));
process.stdout.write(JSON.stringify(saved));
})();
""")
assert output == {
"gate_id": "g1",
"revision": 2,
"checklist": {
"exact_hash": True,
"artifacts_reviewed": True,
"provenance_reviewed": False,
},
"reason": "Waiting for mobile evidence",
"override_reason": "Temporary exception",
}
def test_mobile_decision_tray_reports_readiness_and_targets_the_first_missing_confirmation():
output = run_node(r"""
let posts=0;
const focused=[];
const checklist=[
{dataset:{gateChecklist:'exact_hash'},checked:true,focus(){focused.push('exact_hash')},scrollIntoView(){focused.push('scroll-exact_hash')}},
{dataset:{gateChecklist:'artifacts_reviewed'},checked:false,focus(){focused.push('artifacts_reviewed')},scrollIntoView(){focused.push('scroll-artifacts_reviewed')}},
{dataset:{gateChecklist:'provenance_reviewed'},checked:false,focus(){focused.push('provenance_reviewed')},scrollIntoView(){focused.push('scroll-provenance_reviewed')}},
];
const error={textContent:'',hidden:true};
const readiness={textContent:''};
const buttons=[{disabled:false},{disabled:false}];
const detail={
innerHTML:'', addEventListener(){},
querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:selector==='[data-gate-decision]'?buttons:[],
querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-readiness]'?readiness:selector==='[data-gate-reason]'?{value:''}:selector==='[data-gate-override]'?{value:''}:null,
};
const item={id:'g1',title:'Long evidence review',candidate_hash:'abc',revision:1,checks:[]};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},
fetchJson:async(path,options={})=>{if(options.method==='POST')posts+=1;return options.method==='POST'?{receipt_id:'r1'}:{pending_count:1,items:[item]};},
});
(async()=>{
await gates.load(); gates.reviewNext();
const rendered=detail.innerHTML;
let message='';
try { await gates.submitDecision('release'); } catch(error) { message=error.message; }
process.stdout.write(JSON.stringify({rendered,message,error,readiness,focused,posts}));
})();
""")
assert 'class="human-gate-decision-tray"' in output["rendered"]
assert 'data-gate-readiness' in output["rendered"]
assert output["message"] == "Complete the release checklist before deciding."
assert output["error"] == {
"textContent": "Complete the release checklist before deciding.",
"hidden": False,
}
assert output["readiness"]["textContent"] == "1 of 3 confirmations complete"
assert output["focused"] == ["scroll-artifacts_reviewed", "artifacts_reviewed"]
assert output["posts"] == 0
def test_mobile_decision_tray_is_safe_area_aware_touch_sized_and_does_not_cover_form_fields():
css = CSS.read_text()
assert ".human-gate-decision-tray" in css
assert "position:sticky" in css
assert "bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)))" in css
assert "padding-bottom:max(16px,env(safe-area-inset-bottom))" in css
assert ".human-gate-decision-actions button{min-height:44px}" in css
assert ".human-gate-detail{padding-bottom:" in css
def test_human_gate_decision_tray_frontend_assets_invalidate_the_installed_shell_cache():
worker = WORKER.read_text()
assert "stackchain-dashboard-shell-v150" in worker
def test_unmet_required_check_is_named_inline_and_focuses_the_override_reason():
output = run_node(r"""
const focused=[];
const checklist=['exact_hash','artifacts_reviewed','provenance_reviewed'].map(key=>({dataset:{gateChecklist:key},checked:true}));
const override={value:'',focus(){focused.push('override')},scrollIntoView(){focused.push('scroll-override')}};
const error={textContent:'',hidden:true};
const detail={innerHTML:'',addEventListener(){},querySelectorAll:selector=>selector==='[data-gate-checklist]'?checklist:[],querySelector:selector=>selector==='[data-gate-error]'?error:selector==='[data-gate-override]'?override:selector==='[data-gate-reason]'?{value:''}:null};
const item={id:'g1',title:'Candidate',candidate_hash:'abc',revision:1,checks:[{name:'Mobile browser journey',state:'failure',required:true}]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail},location:{hash:''},fetchJson:async()=>({pending_count:1,items:[item]})});
(async()=>{await gates.load();gates.reviewNext();let message='';try{await gates.submitDecision('release')}catch(error){message=error.message}process.stdout.write(JSON.stringify({message,inline:error.textContent,focused}));})();
""")
assert "Mobile browser journey" in output["message"]
assert output["inline"] == output["message"]
assert output["focused"] == ["scroll-override", "override"]
def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity():
output = run_node(r"""
let online=true, login='timmy', posts=0;
const item={id:'g1',title:'Failing',candidate_hash:'a1',revision:1,checks:[{name:'browser',state:'failure',required:true}]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=>login,isOnline:()=>online,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
(async()=>{await gates.load();gates.reviewNext();let override,offline,identity;try{await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){override=e.message} online=false;try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){offline=e.message} online=true;login='';try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){identity=e.message}process.stdout.write(JSON.stringify({override,offline,identity,posts}));})();
""")
assert "override reason" in output["override"]
assert "online" in output["offline"]
assert "identity" in output["identity"]
assert output["posts"] == 0
def test_offline_cache_is_scoped_to_immutable_account_identity():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let account='1:timmy'; const nodes={count:{},list:{},status:{},panel:{}};
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>({pending_count:1,items:[{id:'g1',title:'Private',candidate_hash:'a1'}]})});
(async()=>{await gates.load();account='2:timmy';process.stdout.write(JSON.stringify({keys:[...values.keys()],restored:gates.restoreCached()}));})();
""")
assert output["keys"] == ["stackchain.human-gates.v1:1:timmy"]
assert output["restored"] is None
def test_account_switch_clears_in_memory_gate_data_before_failed_load():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let account='1:timmy', fail=false; const nodes={count:{},list:{innerHTML:''},status:{},panel:{}};
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>{if(fail)throw new Error('offline');return {pending_count:1,items:[{id:'private-1',title:'Principal 1 private',candidate_hash:'secret'}]}}});
(async()=>{await gates.load();account='2:timmy';fail=true;try{await gates.load()}catch(_){}process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),html:nodes.list.innerHTML}));})();
""")
assert output["snapshot"] == {"pending_count": 0, "items": []}
assert "Principal 1 private" not in output["html"]
assert "secret" not in output["html"]
def test_stale_account_load_cannot_overwrite_new_account_queue_or_cache():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let account='1:timmy', resolveA, resolveB;
const responseA=new Promise(resolve=>resolveA=resolve), responseB=new Promise(resolve=>resolveB=resolve);
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes:{count:{},list:{innerHTML:''},status:{},panel:{}},location:{hash:''},fetchJson:()=>account.startsWith('1:')?responseA:responseB});
(async()=>{const loadA=gates.load();account='2:timmy';const loadB=gates.load();resolveB({pending_count:1,items:[{id:'b',title:'B gate',candidate_hash:'bhash'}]});await loadB;resolveA({pending_count:1,items:[{id:'a-secret',title:'A secret',candidate_hash:'asecret'}]});await loadA;process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),cached:JSON.parse(values.get('stackchain.human-gates.v1:2:timmy'))}));})();
""")
assert [item["id"] for item in output["snapshot"]["items"]] == ["b"]
assert [item["id"] for item in output["cached"]["items"]] == ["b"]
def test_decision_retry_reuses_the_same_idempotency_key():
output = run_node(r"""
let attempts=0; const keys=[];
const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){keys.push(options.headers['Idempotency-Key']);attempts++;if(attempts===1)throw new Error('network');return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};try{await gates.decideAndNext('hold',values)}catch(_){}await gates.decideAndNext('hold',values);process.stdout.write(JSON.stringify({keys}));})();
""")
assert len(output["keys"]) == 2
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;
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:'Candidate',candidate_hash:'a1',revision:3,checks:[]};
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') { attempts += 1; if(attempts===1) throw new Error('offline'); return {receipt_id:'r1'}; }
return {pending_count:1,items:[item]};
},
});
(async()=>{
await gates.load(); gates.reviewNext();
gates.saveProgress({reason:'Awaiting approval',checklist:{}});
const key='stackchain.human-gate-review.v1:7:timmy:g1:3';
try { await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}}); } catch (_) {}
const retained=stored.has(key);
await gates.decideAndNext('hold',{reason:'Awaiting approval',checklist:{}});
process.stdout.write(JSON.stringify({retained,cleared:!stored.has(key)}));
})();
""")
assert output == {"retained": True, "cleared": True}
def test_concurrent_decision_taps_submit_once_and_advance_once():
output = run_node(r"""
let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve);
const items=[{id:'g1',title:'One',candidate_hash:'a1',revision:1,checks:[]},{id:'g2',title:'Two',candidate_hash:'b2',revision:1,checks:[]}];
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;await posted;return {receipt_id:'r1'}};return {pending_count:2,items};}});
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};const first=gates.decideAndNext('hold',values);const second=gates.decideAndNext('hold',values);releasePost();await Promise.all([first,second]);process.stdout.write(JSON.stringify({posts,current:gates.current()?.id,snapshot:gates.snapshot()}));})();
""")
assert output["posts"] == 1
assert output["current"] == "g2"
assert output["snapshot"]["pending_count"] == 1
assert [item["id"] for item in output["snapshot"]["items"]] == ["g2"]
def test_concurrent_reopen_calls_share_one_fresh_list_request():
output = run_node(r"""
let listCalls=0, releaseList;
const pending=new Promise(resolve=>releaseList=resolve);
const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{hidden:true},detail:{}},location:{hash:''},
fetchJson:async path=>{
if(path.endsWith('/g1')) return item;
listCalls += 1;
await pending;
return {pending_count:1,items:[item]};
},
});
(async()=>{
const first=gates.open();
const second=gates.open();
releaseList();
const reviewed=await Promise.all([first,second]);
process.stdout.write(JSON.stringify({listCalls,ids:reviewed.map(item=>item.id)}));
})();
""")
assert output == {"listCalls": 1, "ids": ["g1", "g1"]}
def test_failed_reopen_clears_single_flight_and_can_retry():
output = run_node(r"""
let listCalls=0;
const item={id:'g1',title:'Recovered',candidate_hash:'a1',revision:1,checks:[]};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,
nodes:{count:{},list:{},status:{},panel:{hidden:true},detail:{}},location:{hash:''},
fetchJson:async path=>{
if(path.endsWith('/g1')) return item;
listCalls += 1;
if(listCalls === 1) throw new Error('offline');
return {pending_count:1,items:[item]};
},
});
(async()=>{
let firstError='';
try { await gates.open(); } catch(error) { firstError=error.message; }
const recovered=await gates.open();
process.stdout.write(JSON.stringify({firstError,listCalls,recovered:recovered.id}));
})();
""")
assert output == {"firstError": "offline", "listCalls": 2, "recovered": "g1"}
def test_reopen_refreshes_queue_and_starts_a_fresh_atomic_review_snapshot():
output = run_node(r"""
const first={id:'g1',title:'First candidate',project:'p/one',candidate_hash:'a1',revision:1,checks:[]};
const second={id:'g2',title:'Second candidate',project:'p/two',candidate_hash:'b2',revision:1,checks:[]};
let listCalls=0;
const nodes={count:{},list:{innerHTML:''},status:{},panel:{hidden:true},detail:{innerHTML:''}};
const gates=createHumanGates({
storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},
fetchJson:async path=>{
if(path.endsWith('/g1')) return first;
if(path.endsWith('/g2')) return second;
listCalls += 1;
return {pending_count:1,items:[listCalls === 1 ? first : second]};
},
});
(async()=>{
await gates.open();
await new Promise(resolve=>setTimeout(resolve,0));
const before=gates.current().id;
await gates.open();
const selected=gates.select('g2');
await new Promise(resolve=>setTimeout(resolve,0));
process.stdout.write(JSON.stringify({
before,listCalls,selected:selected.id,current:gates.current().id,
listHtml:nodes.list.innerHTML,detailHtml:nodes.detail.innerHTML,
}));
})();
""")
assert output["before"] == "g1"
assert output["listCalls"] == 2
assert output["selected"] == "g2"
assert output["current"] == "g2"
assert "Second candidate" in output["listHtml"]
assert "Second candidate" in output["detailHtml"]
def test_selecting_a_queue_card_opens_that_exact_gate():
output = run_node(r"""
const details={
g1:{id:'g1',title:'First',project:'p/one',candidate_hash:'a1',revision:1,checks:[]},
g2:{id:'g2',title:'Second',project:'p/two',candidate_hash:'b2',revision:1,checks:[]},
};
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.includes('/g')?details[path.split('/').pop()]:{pending_count:2,items:Object.values(details)}});
(async()=>{await gates.load();gates.reviewNext();gates.select('g2');await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({current:gates.current().id,html:nodes.detail.innerHTML}));})();
""")
assert output["current"] == "g2"
assert "Second" in output["html"]
assert "p/two" in output["html"]
def test_human_gate_mobile_shell_and_deep_route_are_wired():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="human-gates"' in index
assert 'id="human-gates-count"' in index
assert 'Review next <span id="human-gates-count"' in index
assert 'static/human-gates.js' in index
assert "#/my-work/human-gates" in dashboard
assert "createHumanGates" in dashboard
assert "planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id" in dashboard
assert 'data-mobile-queue="gate"' in index
assert 'data-mobile-queue-count="gate"' in index
assert "openHumanGates: () => openHumanGates()" in dashboard
assert "const humanGatesOnChange = (snapshot, state)=>" in dashboard
assert "queueCounts.gate = snapshot.pending_count" in dashboard
assert "preparationItems.gate = snapshot.items" in dashboard
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-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():
script = f"""
const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))});
const listeners={{}}; const requests=[];
const nodes={{
'#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:''}},
'#human-gates-status':{{}}, '#human-gates':{{hidden:true}},
'#human-gate-detail':{{innerHTML:'',querySelectorAll:()=>[]}},
'#open-human-gates':{{addEventListener:(name,fn)=>listeners.open=fn}},
'#close-human-gates':{{addEventListener:(name,fn)=>listeners.close=fn}},
}};
nodes['#human-gates-list'].addEventListener=(name,fn)=>listeners.list=fn;
nodes['#human-gate-detail'].addEventListener=(name,fn)=>listeners.detail=fn;
const document={{querySelector:selector=>nodes[selector]||null}};
const app=createProgressiveHumanGates({{
document, location:{{hash:'#/my-work/human-gates'}},
history:{{replaceState(){{}}}}, storage:{{getItem:()=>null,setItem(){{}}}},
isOnline:()=>true, getIdentity:async()=>({{login:'timmy',accountKey:'7:timmy'}}),
fetchJson:async path=>{{requests.push(path);return {{pending_count:1,items:[{{id:'g1',title:'Ship it',candidate_hash:'abc',revision:1,checks:[]}}]}};}},
}});
(async()=>{{const started=await app.start();process.stdout.write(JSON.stringify({{
started,hidden:nodes['#human-gates'].hidden,html:nodes['#human-gates-list'].innerHTML,
requests,listeners:Object.keys(listeners).sort(),handoff:app.handoff().started,
}}));}})();
"""
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
output = json.loads(result.stdout)
assert output == {
"started": True,
"hidden": False,
"html": '<button class="human-gate-card" type="button" data-human-gate-id="g1"><strong>Ship it</strong><code>abc</code><span>Priority 0</span></button>',
"requests": ["api/v1/human-gates", "api/v1/human-gates/g1"],
"listeners": ["close", "detail", "list", "open"],
"handoff": True,
}
def test_progressive_reopen_refreshes_the_review_session_before_hydration():
script = f"""
const createProgressiveHumanGates=require({json.dumps(str(PROGRESSIVE))});
const listeners={{}};
const nodes={{
'#human-gates-count':{{}}, '#human-gates-list':{{innerHTML:'',addEventListener(){{}}}},
'#human-gates-status':{{}}, '#human-gates':{{hidden:true}},
'#human-gate-detail':{{innerHTML:'',addEventListener(){{}},querySelectorAll:()=>[]}},
'#open-human-gates':{{addEventListener:(name,fn)=>listeners.open=fn}},
'#close-human-gates':{{addEventListener:(name,fn)=>listeners.close=fn}},
}};
let listCalls=0;
const first={{id:'g1',title:'First',candidate_hash:'a1',revision:1,checks:[]}};
const second={{id:'g2',title:'Second',candidate_hash:'b2',revision:1,checks:[]}};
const app=createProgressiveHumanGates({{
document:{{querySelector:selector=>nodes[selector]||null}},
location:{{hash:'#/my-work/human-gates'}}, history:{{replaceState(){{}}}},
storage:{{getItem:()=>null,setItem(){{}}}}, isOnline:()=>true,
getIdentity:async()=>({{login:'timmy',accountKey:'7:timmy'}}),
fetchJson:async path=>{{
if(path.endsWith('/g1')) return first;
if(path.endsWith('/g2')) return second;
listCalls += 1;
return {{pending_count:1,items:[listCalls === 1 ? first : second]}};
}},
}});
(async()=>{{
await app.start();
listeners.close();
await listeners.open();
await new Promise(resolve=>setTimeout(resolve,0));
const controller=app.handoff().controller;
process.stdout.write(JSON.stringify({{
listCalls,current:controller.current().id,hidden:nodes['#human-gates'].hidden,
listHtml:nodes['#human-gates-list'].innerHTML,
detailHasSecond:nodes['#human-gate-detail'].innerHTML.includes('Second'),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
assert json.loads(result.stdout) == {
"listCalls": 2,
"current": "g2",
"hidden": False,
"listHtml": '<button class="human-gate-card" type="button" data-human-gate-id="g2"><strong>Second</strong><code>b2</code><span>Priority 0</span></button>',
"detailHasSecond": True,
}
def test_dashboard_adopts_progressive_human_gates_without_a_second_list_load():
dashboard = DASHBOARD.read_text()
index = INDEX.read_text()
assert 'static/progressive-human-gates.js' in index
assert "window.stackchainProgressiveHumanGates?.handoff?.()" in dashboard
assert "progressiveHumanGatesHandoff?.controller || createHumanGates" in dashboard
assert "if (!progressiveHumanGatesHandoff?.started) humanGates.load()" in dashboard
assert "if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates()" in dashboard

View File

@ -1,24 +0,0 @@
from pathlib import Path
README = Path(__file__).parents[1] / "README.md"
def test_readme_documents_hash_bound_producer_intake_and_revision_decisions():
text = README.read_text()
assert "POST /api/v1/human-gates/intake" in text
assert "Idempotency-Key" in text
assert '"candidate_hash"' in text
assert "expected_revision" in text
assert "STACKCHAIN_HUMAN_GATE_DB" in text
assert "principal ID and login" in text
assert "reopens the exact hash" in text
def test_readme_defines_privacy_safe_telegram_coalescing_contract():
text = README.read_text()
assert "Telegram coalescing contract" in text
assert "#/my-work/human-gates" in text
assert "count and route only" in text
assert "candidate hash" in text
assert "superseded" in text

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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/later-sync.js'" in source

View File

@ -103,87 +103,6 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["revisions"]["context"].endswith(".1")
@pytest.mark.anyio
async def test_notification_timeout_preserves_healthy_context_and_events(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
async def events(_authenticated_user):
return [{"type": "push"}]
async def stalled_notifications():
await asyncio.Event().wait()
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", stalled_notifications)
response = await main.live_snapshot()
result = payload(response)
assert response.status_code == 200
assert result["context"]["user"]["login"] == "timmy"
assert result["events"] == [{"type": "push"}]
assert result["notifications"] is None
assert result["sections"] == {
"context": "fresh",
"events": "fresh",
"notifications": "temporarily unavailable",
}
assert result["freshness"]["sections"]["context"]["degraded"] is False
assert result["freshness"]["sections"]["events"]["degraded"] is False
assert result["freshness"]["sections"]["notifications"]["degraded"] is True
assert main._live_section_failure_count == {
"context": 0,
"events": 0,
"notifications": 1,
}
@pytest.mark.anyio
async def test_identity_timeout_preserves_independent_notifications(monkeypatch):
identity_cancelled = asyncio.Event()
async def stalled_user():
try:
await asyncio.Event().wait()
finally:
identity_cancelled.set()
async def updates():
return [{"id": 42, "title": "Mentioned you"}]
monkeypatch.setattr(main, "CONTEXT_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main, "current_user", stalled_user)
monkeypatch.setattr(main, "notifications", updates)
response = await main.live_snapshot()
result = payload(response)
assert response.status_code == 200
assert result["context"] is None
assert result["events"] is None
assert result["notifications"] == [{"id": 42, "title": "Mentioned you"}]
assert result["sections"] == {
"context": "temporarily unavailable",
"events": "temporarily unavailable",
"notifications": "fresh",
}
assert main._live_section_failure_count == {
"context": 1,
"events": 1,
"notifications": 0,
}
await asyncio.wait_for(identity_cancelled.wait(), timeout=0.1)
@pytest.mark.anyio
async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch):
async def user():

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-v150" in worker
assert "stackchain-dashboard-shell-v139" in worker

View File

@ -88,27 +88,6 @@ console.log(JSON.stringify({values, clears}));
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
def test_app_badge_combines_pending_human_gates_with_other_review_channels():
result = run_badge("""
const values = [];
let clears = 0;
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}},
status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() { clears++; }},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('human-gates', 2, true);
await controller.reconcile('following', 1, true);
await controller.reconcile('human-gates', 0, true);
console.log(JSON.stringify({values, clears}));
""")
assert result == {"values": [3, 5, 6, 4], "clears": 0}
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};

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-v150" in worker
assert "stackchain-dashboard-shell-v139" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

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-v150" in worker
assert "stackchain-dashboard-shell-v139" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

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-v150" in worker
assert "stackchain-dashboard-shell-v139" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -1,491 +0,0 @@
import json
import subprocess
from pathlib import Path
RECENT_WORK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-recent-work.js"
INDEX = Path(__file__).resolve().parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_recent_work_is_account_scoped_deduplicated_and_bounded():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = ' Alice ';
const recent = createRecentWork({{storage, getLogin:() => login, limit:5}});
for (let number=1; number<=6; number += 1) {{
recent.record({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
}}
recent.record({{kind:'issue', repository:'stackchain/dashboard', number:3, title:'Issue 3 updated'}});
const alice = recent.items();
login = 'bob';
recent.record({{kind:'pull', repository:'stackchain/api', number:9, title:'Ship API'}});
const bob = recent.items();
login = '';
const anonymousRecord = recent.record({{kind:'issue', repository:'stackchain/dashboard', number:99, title:'Private'}});
const anonymous = recent.items();
process.stdout.write(JSON.stringify({{alice,bob,anonymousRecord,anonymous,keys:Array.from(values.keys()).sort()}}));
"""
payload = run_node(script)
assert [item["number"] for item in payload["alice"]] == [3, 6, 5, 4, 2]
assert payload["alice"][0] == {
"kind": "issue",
"repository": "stackchain/dashboard",
"number": 3,
"title": "Issue 3 updated",
"route": "#/my-work/issue/stackchain/dashboard/3",
}
assert payload["bob"] == [
{
"kind": "pull",
"repository": "stackchain/api",
"number": 9,
"title": "Ship API",
"route": "#/my-work/pull/stackchain/api/9",
}
]
assert payload["anonymousRecord"] is False
assert payload["anonymous"] == []
assert payload["keys"] == [
"stackchain.mobile-recent-work.v1.alice",
"stackchain.mobile-recent-work.v1.bob",
]
def test_recent_work_renders_safe_rows_and_opens_the_selected_route():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map([
['stackchain.mobile-recent-work.v1.alice', JSON.stringify([
{{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix mobile queue',route:'#/wrong'}},
{{kind:'update',number:42,title:'Review release status'}},
{{kind:'pull',repository:'bad/repo/extra',number:1,title:'Unsafe'}},
{{kind:'issue',repository:'stackchain/dashboard',number:0,title:'Invalid'}},
])],
]);
function node(tag) {{
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
appendChild(child){{this.children.push(child);return child;}},
replaceChildren(...children){{this.children=children;}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}},
}};
}}
const list=node('div'); const section=node('section'); const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice', document:{{createElement:node}}, list, section,
openRoute:route=>opened.push(route),
}});
const rendered=recent.render();
list.children[1].children[0].click();
process.stdout.write(JSON.stringify({{
rendered,hidden:section.hidden,rows:list.children.map(row=>{{const button=row.children[0];return ({{
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
primary:button.children[0].children[0].textContent,
secondary:button.children[0].children[1].textContent,
pin:row.children[1].attributes['data-recent-work-pin'],
}});}}),opened,
}}));
"""
payload = run_node(script)
assert payload == {
"rendered": 2,
"hidden": False,
"rows": [
{
"label": "Open Fix mobile queue, issue stackchain/dashboard #7",
"route": "#/my-work/issue/stackchain/dashboard/7",
"primary": "Fix mobile queue",
"secondary": "Issue · stackchain/dashboard #7",
"pin": "pin",
},
{
"label": "Open Review release status, update #42",
"route": "#/my-work/update/42",
"primary": "Review release status",
"secondary": "Update · #42",
"pin": "pin",
},
],
"opened": ["#/my-work/update/42"],
}
def test_recent_work_records_offline_first_then_merges_the_server_snapshot():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values = new Map(); const calls=[]; const status={{textContent:''}};
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}};
const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}};
const recent=createRecentWork({{
storage,getLogin:()=>'alice',status,debounceMs:99999,
fetchJson:async (url, options={{}})=>{{
calls.push([url,options.method||'GET']);
return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}};
}},
}});
recent.record(local);
const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}};
await recent.sync();
process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["immediate"]["items"][0]["number"] == 7
assert payload["immediate"]["status"] == "Sync pending."
assert payload["immediate"]["state"]["pending"] is True
assert [item["number"] for item in payload["settled"]] == [7, 9]
assert payload["status"] == ""
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
def test_recent_work_drains_a_newer_same_route_generation_after_an_inflight_response():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values = new Map(); const calls=[];
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old title'}};
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New title'}};
let releaseFirst;
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
const recent=createRecentWork({{
storage,getLogin:()=>'alice',debounceMs:99999,
fetchJson:async (_url, options)=>{{
const sent=JSON.parse(options.body);
calls.push(sent.title);
if (calls.length === 1) return firstResponse;
return {{items:[{{...newItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}};
}},
}});
recent.record(oldItem);
const syncing=recent.sync();
await Promise.resolve();
recent.record(newItem);
releaseFirst({{items:[{{...oldItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}});
await syncing;
process.stdout.write(JSON.stringify({{calls,items:recent.items(),state:recent.state()}}));
process.exit(0);
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["calls"] == ["Old title", "New title"]
assert payload["items"][0]["title"] == "New title"
assert payload["state"] == {"pending": False, "pendingCount": 0}
def test_recent_work_retries_a_transient_sync_failure_without_a_lifecycle_event():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values=new Map(); const timers=[]; let calls=0;
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',debounceMs:99999,retryMs:25,
setTimeout:(callback,delay)=>{{const timer={{callback,delay,cleared:false}};timers.push(timer);return timer;}},
clearTimeout:timer=>{{timer.cleared=true;}},
fetchJson:async (_url,options)=>{{
calls += 1;
if (calls === 1) throw new Error('temporary outage');
return {{items:[JSON.parse(options.body)],pinned:[]}};
}},
}});
recent.record({{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Keep me'}});
await recent.sync();
const afterFailure={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).map(timer=>timer.delay)}};
const retryTimer=timers.find(timer=>!timer.cleared);
retryTimer?.callback();
await new Promise(resolve=>setImmediate(resolve));
const settled={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).length}};
process.stdout.write(JSON.stringify({{afterFailure,settled}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["afterFailure"] == {
"calls": 1,
"state": {"pending": True, "pendingCount": 1},
"active": [25],
}
assert payload["settled"] == {
"calls": 2,
"state": {"pending": False, "pendingCount": 0},
"active": 0,
}
def test_recent_work_drains_a_newer_pin_generation_after_an_inflight_response():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values=new Map(); const calls=[];
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old pin'}};
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New pin'}};
const route='#/my-work/issue/stackchain/dashboard/7';
let releaseFirst;
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',debounceMs:99999,
fetchJson:async (_url,options)=>{{
const sent=JSON.parse(options.body); calls.push(sent.title);
if (calls.length === 1) return firstResponse;
return {{items:[],pinned:[{{...newItem,route}}]}};
}},
}});
recent.pin(oldItem);
const syncing=recent.sync();
await Promise.resolve();
recent.pin(newItem);
releaseFirst({{items:[],pinned:[{{...oldItem,route}}]}});
await syncing;
process.stdout.write(JSON.stringify({{calls,pinned:recent.pinned(),state:recent.state()}}));
process.exit(0);
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["calls"] == ["Old pin", "New pin"]
assert payload["pinned"][0]["title"] == "New pin"
assert payload["state"] == {"pending": False, "pendingCount": 0}
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const item={{kind:'issue',repository:'stackchain/dashboard',number:1477,title:'Pin frequent work',route:'#/my-work/issue/stackchain/dashboard/1477'}};
const storageKey='stackchain.mobile-recent-work.v1.alice';
const values=new Map([[storageKey,JSON.stringify({{items:[item],pinned:[],pending:[],pinOps:[]}})]]);
const status={{textContent:''}}; const calls=[]; const opened=[];
function node(tag) {{
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
appendChild(child){{this.children.push(child);return child;}},
replaceChildren(...children){{this.children=children;}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
}};
}}
const recentList=node('div'); const recentSection=node('section');
const pinnedList=node('div'); const pinnedSection=node('section');
let remote={{items:[item],pinned:[]}};
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',status,debounceMs:99999,document:{{createElement:node}},
list:recentList,section:recentSection,pinnedList,pinnedSection,
openRoute:route=>opened.push(route),
fetchJson:async (url,options={{}})=>{{
calls.push([url,options.method||'GET',JSON.parse(options.body||'null')]);
if (options.method==='PUT') remote={{items:[item],pinned:[item]}};
if (options.method==='DELETE') remote={{items:[item],pinned:[]}};
return remote;
}},
}});
const pinnedImmediately=recent.pin(item);
recent.render();
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
recentRows:recentList.children.length,
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
await recent.sync();
pinnedList.children[0].children[0].click();
const unpinnedImmediately=recent.unpin(item.route);
await recent.sync();
process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinnedImmediately,settled:recent.pinned(),calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["pinnedImmediately"] is True
assert payload["immediate"]["pinned"][0]["number"] == 1477
assert payload["immediate"]["status"] == "Sync pending."
assert payload["immediate"]["recentHidden"] is True
assert payload["immediate"]["pinnedHidden"] is False
assert payload["immediate"]["recentRows"] == 0
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
assert payload["unpinnedImmediately"] is True
assert payload["settled"] == []
assert payload["calls"] == [
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
["api/v1/recent-work", "POST", payload["immediate"]["pinned"][0]],
[
"api/v1/recent-work/pin",
"DELETE",
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
],
]
def test_current_detail_pin_follows_route_and_toggles_offline_first():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values=new Map();
function button() {{ return {{hidden:true,textContent:'',attributes:{{}},listeners:{{}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}},
}}; }}
const detailPins=[button(),button(),button(),button()];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',detailPins,debounceMs:99999,
}});
const issue={{kind:'issue',repository:'stackchain/dashboard',number:1489,title:'Pin current detail'}};
const pull={{kind:'pull',repository:'stackchain/api',number:20,title:'Ship API'}};
recent.setCurrent(issue);
const issueReady=detailPins.map(pin=>({{hidden:pin.hidden,text:pin.textContent,
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}));
detailPins[0].click();
const pinned={{items:recent.pinned(),state:recent.state(),buttons:detailPins.map(pin=>({{text:pin.textContent,
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}))}};
recent.setCurrent(pull);
const pullReady={{text:detailPins[1].textContent,label:detailPins[1].attributes['aria-label'],pressed:detailPins[1].attributes['aria-pressed']}};
recent.setCurrent(issue);
detailPins[2].click();
const unpinned={{items:recent.pinned(),recent:recent.items(),state:recent.state(),text:detailPins[0].textContent}};
recent.setCurrent(null);
process.stdout.write(JSON.stringify({{issueReady,pinned,pullReady,unpinned,hidden:detailPins.map(pin=>pin.hidden)}}));
"""
payload = run_node(script)
assert payload["issueReady"] == [
{
"hidden": False,
"text": "Pin",
"pressed": "false",
"label": "Pin Pin current detail",
"action": "pin",
}
] * 4
assert payload["pinned"]["items"][0]["route"] == "#/my-work/issue/stackchain/dashboard/1489"
assert payload["pinned"]["state"] == {"pending": True, "pendingCount": 1}
assert payload["pinned"]["buttons"] == [
{
"text": "Pinned",
"pressed": "true",
"label": "Unpin Pin current detail",
"action": "unpin",
}
] * 4
assert payload["pullReady"] == {
"text": "Pin",
"label": "Pin Ship API",
"pressed": "false",
}
assert payload["unpinned"]["items"] == []
assert payload["unpinned"]["recent"] == []
assert payload["unpinned"]["state"] == {"pending": True, "pendingCount": 1}
assert payload["unpinned"]["text"] == "Pin"
assert payload["hidden"] == [True] * 4
def test_pinned_work_is_compact_deduplicated_and_promotes_on_open():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const makeItem=number=>({{kind:'issue',repository:'stackchain/dashboard',number,title:'Issue '+number}});
const values=new Map([['stackchain.mobile-recent-work.v1.alice',JSON.stringify({{
items:[1,2,3,4,5].map(makeItem),pinned:[1,2,3,4,5].map(makeItem),pending:[],pinOps:[]
}})]]);
function node(tag) {{ return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
appendChild(child){{this.children.push(child);return child;}},
replaceChildren(...children){{this.children=children;}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
}}; }}
const recentList=node('div'), recentSection=node('section');
const pinnedList=node('div'), pinnedSection=node('section'), pinnedToggle=node('button');
const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',document:{{createElement:node}},debounceMs:99999,
list:recentList,section:recentSection,pinnedList,pinnedSection,pinnedToggle,
openRoute:route=>opened.push(route),
}});
recent.render();
const collapsed={{pinnedRows:pinnedList.children.length,recentRows:recentList.children.length,
recentHidden:recentSection.hidden,toggleHidden:pinnedToggle.hidden,toggleText:pinnedToggle.textContent,
expanded:pinnedToggle.attributes['aria-expanded'],controls:pinnedToggle.attributes['aria-controls']}};
pinnedList.children[2].children[0].click();
const promoted=recent.pinned().map(item=>item.number);
pinnedToggle.click();
const expanded={{pinnedRows:pinnedList.children.length,toggleText:pinnedToggle.textContent,
ariaExpanded:pinnedToggle.attributes['aria-expanded']}};
process.stdout.write(JSON.stringify({{collapsed,promoted,expanded,opened,state:recent.state()}}));
"""
payload = run_node(script)
assert payload["collapsed"] == {
"pinnedRows": 3,
"recentRows": 0,
"recentHidden": True,
"toggleHidden": False,
"toggleText": "Show all 5",
"expanded": "false",
"controls": "mobile-pinned-work-list",
}
assert payload["promoted"] == [3, 1, 2, 4, 5]
assert payload["expanded"] == {
"pinnedRows": 5,
"toggleText": "Show fewer",
"ariaExpanded": "true",
}
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/3"]
assert payload["state"] == {"pending": True, "pendingCount": 1}
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="mobile-recent-work"' in html
assert 'id="mobile-recent-work-list"' in html
assert 'id="mobile-pinned-work"' in html
assert 'id="mobile-pinned-work-list"' in html
assert 'id="mobile-pinned-work-toggle"' in html
assert 'aria-controls="mobile-pinned-work-list"' in html
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
assert '<script src="static/mobile-recent-work.js"></script>' in html
assert "createMobileRecentWork({" in dashboard
assert "fetchJson:fetchReviewJson" in dashboard
assert "status:qs('#mobile-recent-work-status')" in dashboard
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
assert "pinnedToggle:qs('#mobile-pinned-work-toggle')" in dashboard
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
assert "void mobileRecentWork.load();" in dashboard
assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard
assert html.count('data-current-work-pin') == 4
assert "detailPins:qsa('[data-current-work-pin]')" in dashboard
assert "mobileRecentWork.setCurrent(item)" in dashboard
assert "mobileRecentWork.setCurrent(null)" in dashboard
assert "[data-recent-work-route]" in css
assert "[data-recent-work-pin]" in css
assert "[data-current-work-pin]" in css
assert "min-height:44px" in css
assert "min-width:0" in css

View File

@ -110,61 +110,6 @@ process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
assert output["handoffs"] == ["agenda"]
def test_prepare_today_reviews_human_gates_after_delivery_before_agenda():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{delivery:1, gate:2, agenda:1, today:4}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 4,
"next": "delivery",
"label": "Review Delivery",
"summary": "1 delivery needs action before Today · 3 other items · 4 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 1},
{"name": "gate", "label": "Human Gates", "count": 2},
{"name": "agenda", "label": "Agenda", "count": 1},
],
},
"opened": ["delivery"],
}
def test_prepare_today_keeps_unavailable_human_gates_retryable_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{gateUnavailable:true, today:2}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
assert run_node(script) == {
"briefing": {
"total": 0,
"next": "gate",
"label": "Retry Human Gates",
"summary": "Human Gates need retry before Today · 2 planned",
"phases": [{"name": "gate", "label": "Human Gates unavailable · retry", "count": 0}],
},
"opened": ["gate"],
}
def test_prepare_today_counts_each_work_identity_in_only_its_highest_priority_phase():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
@ -295,25 +240,6 @@ 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))});
@ -459,22 +385,6 @@ 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()
@ -483,7 +393,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-queue-next-action"' in html
assert 'id="mobile-start-day-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
@ -500,11 +410,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-queue-next button { min-height:48px; width:100%;" in html
assert ".mobile-start-day-action { width:100%; min-height:48px;" 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-v150" in service_worker
assert "stackchain-dashboard-shell-v139" in service_worker
@pytest.mark.anyio
@ -516,7 +426,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-queue-next-action').focus();" in html
assert "qs('#mobile-start-day-action').focus();" in html
assert "let preparationItems = {};" in html
assert "getPhaseItems: () => preparationItems" in html
assert "preparationItems = {" in html

View File

@ -10,7 +10,6 @@ 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"
@ -49,282 +48,6 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
}
def test_mobile_queue_priority_persists_complete_account_scoped_routine_order():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = 'alice';
const priority = createPriority({{storage, getLogin:() => login}});
const original = priority.getOrder();
priority.move('following', -1);
priority.move('following', -1);
priority.move('following', -1);
const alice = priority.getOrder();
login = 'bob';
const bob = priority.getOrder();
login = '';
const anonymous = priority.getOrder();
login = 'alice';
priority.reset();
process.stdout.write(JSON.stringify({{
original, alice, bob, anonymous, reset:priority.getOrder(), keys:Array.from(values.keys()),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
default = ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"]
assert json.loads(result.stdout) == {
"original": default,
"alice": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
"bob": default,
"anonymous": default,
"reset": default,
"keys": [],
}
def test_mobile_queue_priority_hydrates_syncs_and_preserves_local_order_on_conflict():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map();
const defaults = ['attention','today','update','agenda','following','authored','filed','later','draft'];
const remote = defaults.slice(); remote.splice(remote.indexOf('following'), 1); remote.splice(1, 0, 'following');
let server = {{revision:2, order:remote.slice()}};
let conflict = false;
const calls = [];
const fetchJson = async (url, init={{}}) => {{
calls.push([url, init.method || 'GET']);
if (!init.method) return JSON.parse(JSON.stringify(server));
const payload = JSON.parse(init.body);
if (conflict) {{
const error = new Error('conflict'); error.status=409;
error.payload={{detail:{{snapshot:JSON.parse(JSON.stringify(server))}}}};
throw error;
}}
server={{revision:payload.revision + 1, order:payload.order.slice()}};
return JSON.parse(JSON.stringify(server));
}};
(async () => {{
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson,
}});
await priority.load();
const hydrated=priority.getOrder();
priority.move('authored', -1);
const local=priority.getOrder();
conflict=true;
server={{revision:3, order:defaults.slice()}};
await priority.sync();
const conflicted=priority.state();
conflict=false;
await priority.useLocal();
process.stdout.write(JSON.stringify({{hydrated,local,conflicted,settled:priority.state(),server,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["hydrated"][1] == "following"
assert payload["local"] == payload["conflicted"]["order"]
assert payload["conflicted"]["status"] == "conflict"
assert payload["conflicted"]["remote"] == {
"revision": 3,
"order": ["attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"],
}
assert payload["settled"]["status"] == "ready"
assert payload["server"]["revision"] == 4
assert payload["server"]["order"] == payload["local"]
assert payload["calls"] == [
["api/v1/queue-priority", "GET"],
["api/v1/queue-priority", "PUT"],
["api/v1/queue-priority", "PUT"],
]
def test_mobile_queue_priority_coalesces_rapid_edits_and_publishes_latest_order():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const payloads=[]; const timers=[];
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
const fetchJson = async (_url, init) => {{
const payload=JSON.parse(init.body); payloads.push(payload);
return {{revision:payload.revision+1,order:payload.order}};
}};
(async () => {{
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer, debounceMs:150,
}});
for (let index=0; index<4; index += 1) priority.move('following', -1);
const before=payloads.length;
const active=timers.filter(timer=>!timer.cancelled);
active[0].callback();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{before,active:active.map(timer=>timer.delay),payloads,state:priority.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["before"] == 0
assert payload["active"] == [150]
assert len(payload["payloads"]) == 1
assert payload["payloads"][0]["revision"] == 0
assert payload["payloads"][0]["order"] == payload["state"]["order"]
assert payload["state"]["revision"] == 1
assert payload["state"]["status"] == "ready"
def test_mobile_queue_priority_retries_transient_failure_without_another_edit():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const timers=[]; let calls=0;
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
const fetchJson = async (_url, init) => {{
calls += 1;
if (calls === 1) {{ const error=new Error('temporary'); error.status=503; throw error; }}
const payload=JSON.parse(init.body);
return {{revision:payload.revision+1,order:payload.order}};
}};
(async () => {{
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer,
debounceMs:150, retryBaseMs:1000, retryMaxMs:30000,
}});
priority.move('following', -1);
timers.find(timer=>!timer.cancelled).callback();
await new Promise(resolve => setImmediate(resolve));
const afterFailure=priority.state();
const retry=timers.filter(timer=>!timer.cancelled).at(-1);
retry.callback();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{calls,afterFailure,retryDelay:retry.delay,settled:priority.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["afterFailure"]["pending"] is True
assert payload["afterFailure"]["status"] == "pending"
assert payload["retryDelay"] == 1000
assert payload["calls"] == 2
assert payload["settled"]["pending"] is False
assert payload["settled"]["status"] == "ready"
def test_mobile_queue_priority_refreshes_clean_device_when_foregrounded():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const windowListeners={{}}; const documentListeners={{}};
const remoteOrder=['attention','following','today','update','agenda','authored','filed','later','draft'];
let calls=0;
const documentRef={{hidden:false,addEventListener:(name, callback)=>documentListeners[name]=callback}};
const windowRef={{addEventListener:(name, callback)=>windowListeners[name]=callback}};
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson:async () => {{ calls += 1; return {{revision:4,order:remoteOrder.slice()}}; }},
}});
(async () => {{
priority.startLifecycle({{window:windowRef,document:documentRef}});
documentListeners.visibilitychange();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{calls,state:priority.state(),listeners:[...Object.keys(windowListeners),...Object.keys(documentListeners)]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert set(payload["listeners"]) == {"online", "visibilitychange"}
assert payload["calls"] == 1
assert payload["state"]["revision"] == 4
assert payload["state"]["order"][1] == "following"
assert payload["state"]["status"] == "ready"
def test_mobile_queue_priority_discards_in_flight_results_after_account_switch():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); let login='alice'; let rejectLoad;
const fetchJson = () => new Promise((_resolve, reject) => {{ rejectLoad=reject; }});
(async () => {{
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>login, fetchJson,
}});
const loading=priority.load();
login='bob';
rejectLoad(new Error('alice offline'));
await loading;
process.stdout.write(JSON.stringify({{state:priority.state(),keys:Array.from(values.keys())}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["state"]["status"] == "ready"
assert payload["state"]["order"] == [
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft"
]
assert payload["keys"] == []
def test_mobile_queue_priority_renders_keyboard_controls_and_updates_immediately():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
class Element {{
constructor(tag='div') {{ this.tag=tag; this.children=[]; this.listeners={{}}; this.attributes={{}}; this.disabled=false; this.textContent=''; }}
append(...items) {{ this.children.push(...items); }}
replaceChildren(...items) {{ this.children=[...items]; }}
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
click() {{ this.listeners.click?.(); }}
}}
const values = new Map();
const list = new Element(); const resetButton = new Element('button'); const status = new Element();
const priority = createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', document:{{createElement:tag=>new Element(tag)}}, list, resetButton, status,
labels:{{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'}},
}});
priority.start();
for (let index=0; index<3; index += 1) {{
const row = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
row.children[1].children[0].click();
}}
const following = list.children.find(item => item.attributes['data-queue-priority'] === 'following');
process.stdout.write(JSON.stringify({{
order:list.children.map(item => item.attributes['data-queue-priority']),
earlierLabel:following.children[1].children[0].attributes['aria-label'],
laterLabel:following.children[1].children[1].attributes['aria-label'],
status:status.textContent,
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"order": ["attention", "following", "today", "update", "agenda", "authored", "filed", "later", "draft"],
"earlierLabel": "Move Following earlier",
"laterLabel": "Move Following later",
"status": "Sync pending.",
}
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
@ -556,97 +279,6 @@ process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
}
@pytest.mark.anyio
async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_duplicate_rows():
html = await dashboard()
assert 'aria-labelledby="mobile-queue-next-heading"' in html
assert 'id="mobile-queue-next-action"' in html
assert 'aria-labelledby="mobile-queue-active-heading"' in html
assert 'id="mobile-queue-active-list"' in html
assert 'aria-labelledby="mobile-queue-planning-heading"' in html
assert 'id="mobile-queue-planning-list"' in html
assert '<details class="mobile-queue-all"' in html
assert 'id="mobile-queue-all-list"' in html
assert "nextAction: qs('#mobile-queue-next-action')" in html
assert "activeList: qs('#mobile-queue-active-list')" in html
assert "mobileQueueLauncher.renderPresentation();" in html
assert "queueCounts.followingUnavailable = status === 'error';\n renderMobileQueuePresentation();" in html
assert "mobileQueueLauncher.continueWork()" in html
for name in ("today", "tomorrow", "week", "agenda", "delivery", "gate", "attention", "update", "following", "filed", "authored", "later", "draft", "find", "recaps"):
assert html.count(f'<button data-mobile-queue="{name}"') == 1
@pytest.mark.anyio
async def test_mobile_queue_priority_is_packaged_account_scoped_and_touch_safe():
html = await dashboard()
assert 'id="mobile-queue-priority"' in html
assert 'id="mobile-queue-priority-list"' in html
assert 'id="reset-mobile-queue-priority"' in html
assert 'id="mobile-queue-priority-status" role="status" aria-live="polite"' in html
assert '<script src="static/mobile-queue-priority.js"></script>' in html
assert "getRoutineOrder: () => mobileQueuePriority?.getOrder()" in html
assert "createMobileQueuePriority({" in html
assert "getLogin: () => confirmedOwnerLogin" in html
assert "mobileQueuePriority.render();\n renderMobileQueuePresentation();" in html
assert ".mobile-queue-priority-controls button { min-height:44px;" in html
@pytest.mark.anyio
async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_conflict_actions():
html = await dashboard()
assert 'id="mobile-queue-priority-conflict"' in html
assert 'id="keep-local-mobile-queue-priority"' in html
assert 'id="use-remote-mobile-queue-priority"' in html
assert "fetchJson: fetchReviewJson" in html
assert "void mobileQueuePriority.load();" in html
assert "mobileQueuePriority.startLifecycle({window, document});" in html
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
assert "error.payload = payload;" in html
@pytest.mark.anyio
async def test_adaptive_mobile_queue_wiring_recommends_again_when_connectivity_changes():
html = await dashboard()
assert "let offlineWorkMode = false;" in html
assert "isOnline: () => !offlineWorkMode" in html
assert (
"offlineWorkMode = value;\n"
" renderMobileQueuePresentation();"
) in html
@pytest.mark.anyio
async def test_following_hydration_refreshes_the_persistent_mobile_queue_census():
html = await dashboard()
assert (
"preparationItems.following = items.filter(item => item.has_unseen_change === true);\n"
" renderMobileQueuePresentation();\n"
" mobileTaskDock.updateQueues(queueCounts);\n"
" mobileStartDay.render();"
) in html
assert (
"queueCounts.followingUnavailable = status === 'error';\n"
" renderMobileQueuePresentation();\n"
" mobileTaskDock.updateQueues(queueCounts);\n"
" mobileStartDay.render();"
) in html
@pytest.mark.anyio
async def test_my_work_refresh_preserves_the_hydrated_following_queue():
html = await dashboard()
assert "counts.following = queueCounts.following;" in html
assert "counts.followingUnavailable = queueCounts.followingUnavailable;" in html
assert "following:preparationItems.following || []," in html
@pytest.mark.anyio
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
html = await dashboard()
@ -837,7 +469,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const deadline = new FakeElement();
const rows = Object.fromEntries(['today','agenda','delivery','gate','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const rows = Object.fromEntries(['today','agenda','delivery','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
@ -848,7 +480,7 @@ const dock = createDock({{
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, agenda:5, delivery:1, gate:2, attention:1, update:5, filed:2, later:3, draft:4}});
dock.updateQueues({{today:2, agenda:5, delivery:1, attention:1, update:5, filed:2, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.update.click();
@ -893,9 +525,9 @@ process.stdout.write(JSON.stringify({{
"utilities": ["recaps:trigger"],
"badge": "0 active",
"populated": {
"badge": "8 active",
"badge": "7 active",
"badgeHidden": False,
"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",
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 7 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
@ -904,72 +536,19 @@ 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, Following 0, Filed 0, My PRs 0, Later 0, Drafts 0; 1 active queue",
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "delivery": "0", "gate": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"counts": {"today": "0", "agenda": "0", "delivery": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
"columnState": None,
}
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))});
@ -1020,27 +599,6 @@ process.stdout.write(JSON.stringify({{result, calls}}));
}
def test_mobile_queue_launcher_uses_existing_human_gate_review_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
openHumanGates: () => {{ calls.push('human-gates'); return 'opened-gates'; }},
selectFilter: name => calls.push('generic-filter:' + name),
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
announce: message => calls.push('announce:' + message),
}});
process.stdout.write(JSON.stringify({{result:launcher.open('gate'), calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "opened-gates",
"calls": ["human-gates"],
}
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
@ -1063,7 +621,7 @@ def test_mobile_work_prioritizes_and_revalidates_actionable_delivery_recovery():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{delivery:2, gate:1, attention:3, today:1, update:4}};
let counts = {{delivery:2, attention:3, today:1, update:4}};
const launcher = createLauncher({{
getCounts: () => counts,
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened-delivery'; }},
@ -1075,7 +633,7 @@ const launcher = createLauncher({{
}});
const delivery = launcher.recommend();
const opened = launcher.continueWork();
counts = {{delivery:0, gate:1, attention:0, today:0, update:4}};
counts = {{delivery:0, attention:0, today:0, update:4}};
const afterRecovery = launcher.recommend();
process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}));
"""
@ -1085,41 +643,11 @@ process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}))
assert json.loads(result.stdout) == {
"delivery": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
"opened": "opened-delivery",
"afterRecovery": {"name": "gate", "count": 1, "label": "Review Human Gates (1)"},
"afterRecovery": {"name": "update", "count": 4, "label": "Resume Updates (4)"},
"calls": ["delivery-recovery"],
}
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))});
@ -1147,42 +675,6 @@ 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))});
@ -1218,142 +710,6 @@ 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))});
const counts = {{delivery:1, gate:2, today:3, authored:4, update:0, attentionUnavailable:true, followingUnavailable:true}};
const launcher = createLauncher({{getCounts:() => counts}});
process.stdout.write(JSON.stringify(launcher.presentation()));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"nextUp": {"name": "delivery", "count": 1, "label": "Recover Delivery (1)"},
"active": [
{"name": "delivery", "count": 1},
{"name": "gate", "count": 2},
{"name": "today", "count": 3},
{"name": "authored", "count": 4},
{"name": "attention", "unavailable": True},
{"name": "following", "unavailable": True},
],
"planning": ["today", "tomorrow", "week"],
"all": [
"today", "tomorrow", "week", "agenda", "delivery", "gate",
"attention", "update", "following", "filed", "authored", "later",
"draft", "find", "recaps",
],
}
def test_mobile_queue_launcher_renders_one_set_of_rows_into_adaptive_groups():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
class Box {{
constructor(name) {{ this.name=name; this.children=[]; this.hidden=false; this.attributes={{}}; this.textContent=''; this.dataset={{}}; }}
append(row) {{ if (row.parent) row.parent.children=row.parent.children.filter(item => item !== row); this.children.push(row); row.parent=this; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
}}
const names=['today','tomorrow','week','agenda','delivery','gate','attention','update','following','filed','authored','later','draft','find','recaps'];
const rows=Object.fromEntries(names.map(name => [name,new Box(name)]));
const nextAction=new Box('next');
const activeList=new Box('active'); const planningList=new Box('planning'); const allList=new Box('all');
const activeSection=new Box('active-section');
const launcher=createLauncher({{
getCounts:()=>({{delivery:1,gate:2,today:3,attentionUnavailable:true}}), rows,
nextAction, activeList, planningList, allList, activeSection,
}});
launcher.renderPresentation();
process.stdout.write(JSON.stringify({{
next:[nextAction.textContent,nextAction.dataset.queue,nextAction.attributes['aria-label']],
active:activeList.children.map(row => [row.name,row.attributes['data-unavailable'] || null]),
planning:planningList.children.map(row => row.name),
all:allList.children.map(row => row.name), activeHidden:activeSection.hidden,
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"next": ["Recover Delivery (1)", "delivery", "Next up: Recover Delivery (1)"],
"active": [["delivery", None], ["gate", None], ["attention", "true"]],
"planning": ["today", "tomorrow", "week"],
"all": ["agenda", "update", "following", "filed", "authored", "later", "draft", "find", "recaps"],
"activeHidden": False,
}
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
@ -1389,7 +745,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','following','authored','filed','later','draft']) {{
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
dock.updateWork(mode);
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
}}
@ -1403,8 +759,6 @@ 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"],

View File

@ -3,50 +3,6 @@ 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"
@ -160,12 +116,11 @@ 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", principal_id=42).sign_count == 5
assert first.get(b"phone-credential").sign_count == 5
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
@ -178,7 +133,6 @@ 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",
@ -186,7 +140,6 @@ 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

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-v150" in source
assert "stackchain-dashboard-shell-v139" 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

View File

@ -107,9 +107,7 @@ 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", principal_id=42
),
lambda path: SecurityEventStore(path, clock=lambda: 1).record("sign_in"),
lambda path: LoginAttemptStore(
path, clock=lambda: 1, max_failures=3, window_seconds=60
).record_failure("203.0.113.10"),

View File

@ -1,184 +0,0 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "progressive-mobile-dock.js"
INDEX = ROOT / "frontend" / "index.html"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
CSS = ROOT / "frontend" / "dashboard.css"
MY_WORK = ROOT / "frontend" / "my-work.js"
PROGRESSIVE_MY_WORK = ROOT / "frontend" / "progressive-my-work.js"
def test_progressive_mobile_dock_opens_work_and_truthful_queues_before_hydration():
harness = f"""
const createDock=require({json.dumps(str(MODULE))});
function element(dataset={{}}) {{
return {{dataset,attrs:{{}},listeners:{{}},open:false,textContent:'',focusCount:0,
addEventListener(name,cb){{this.listeners[name]=cb;}},
removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}},
setAttribute(name,value){{this.attrs[name]=String(value);}},
removeAttribute(name){{delete this.attrs[name];}},
showModal(){{this.open=true;}},close(){{this.open=false;}},focus(){{this.focusCount+=1;}},
}};
}}
const work=element({{mobileTask:'work'}}); const queues=element({{mobileTask:'queues'}});
const sheet=element(); const close=element(); const next=element();
const rows=Object.fromEntries(['today','agenda','delivery','gate','attention','update','filed','later','draft','authored']
.map(name=>[name,element({{mobileQueue:name}})]));
const counts=Object.fromEntries(Object.keys(rows).map(name=>[name,element({{mobileQueueCount:name}})]));
const smalls=Object.fromEntries(Object.keys(rows).map(name=>[name,{{textContent:'source label'}}]));
Object.keys(rows).forEach(name=>rows[name].querySelector=selector=>selector==='small'?smalls[name]:null);
const document={{
querySelector(selector){{return {{
'#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next,
'[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues,
}}[selector]||null;}},
querySelectorAll(selector){{
if(selector==='[data-mobile-queue]')return Object.values(rows);
if(selector==='[data-mobile-queue-count]')return Object.values(counts);
return [];
}},
}};
const calls=[];
const myWork={{
counts:()=>({{all:3,filed:1,authored:1,attention:1,update:0}}),
openFirst:()=>calls.push(['openFirst']),
selectQueue:(name,options)=>calls.push(['selectQueue',name,options]),
}};
const dock=createDock({{document,myWork}}); dock.start();
queues.listeners.click({{currentTarget:queues}});
const opened=sheet.open; const initialHandoff=dock.handoff();
rows.filed.listeners.click();
work.listeners.click({{currentTarget:work}});
const known={{filed:counts.filed.textContent,attention:counts.attention.textContent}};
const loading={{
deferred:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent,gate:smalls.gate.textContent,later:smalls.later.textContent}},
unavailable:{{today:rows.today.attrs['data-unavailable'],delivery:rows.delivery.attrs['data-unavailable']}},
}};
dock.stop();
console.log(JSON.stringify({{
opened,initialHandoff,calls,loading,known,
restored:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent}},
cleaned:!('data-unavailable' in rows.today.attrs) && !('data-progressive-loading' in rows.today.attrs),
listeners:{{work:Object.keys(work.listeners),queues:Object.keys(queues.listeners),filed:Object.keys(rows.filed.listeners)}},
}}));
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": True,
"initialHandoff": {"queueSheetOpen": True, "lastTask": "queues"},
"calls": [
["selectQueue", "filed", {"openFirst": True}],
["openFirst"],
],
"known": {"filed": "1", "attention": "1"},
"loading": {
"deferred": {
"today": "Still loading",
"delivery": "Still loading",
"gate": "Still loading",
"later": "Still loading",
},
"unavailable": {"today": "true", "delivery": "true"},
},
"restored": {"today": "source label", "delivery": "source label"},
"cleaned": True,
"listeners": {"work": [], "queues": [], "filed": []},
}
def test_progressive_mobile_dock_refreshes_counts_from_live_work_and_unsubscribes_on_handoff():
harness = f"""
const createDock=require({json.dumps(str(MODULE))});
function element(dataset={{}}) {{return {{dataset,textContent:'',listeners:{{}},attrs:{{}},open:false,
addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(name){{delete this.listeners[name];}},
setAttribute(name,value){{this.attrs[name]=String(value);}},removeAttribute(name){{delete this.attrs[name];}},
querySelector:()=>({{textContent:''}}),showModal(){{this.open=true;}},close(){{this.open=false;}},
}};}}
const work=element({{mobileTask:'work'}}),queues=element({{mobileTask:'queues'}}),sheet=element(),close=element(),next=element();
const filed=element({{mobileQueue:'filed'}}),filedCount=element({{mobileQueueCount:'filed'}});
const document={{querySelector:s=>({{
'[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues,
'#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next,
}}[s]||null),querySelectorAll:s=>s==='[data-mobile-queue]'?[filed]:s==='[data-mobile-queue-count]'?[filedCount]:[]}};
let counts={{all:0,filed:0}}, subscriber=null, unsubscribed=false;
const myWork={{counts:()=>counts,subscribe(cb){{subscriber=cb;return()=>{{unsubscribed=true;subscriber=null;}};}},openFirst(){{}},selectQueue(){{}}}};
const dock=createDock({{document,myWork}}); dock.start();
const before=filedCount.textContent; counts={{all:2,filed:2}}; subscriber(); const after=filedCount.textContent;
dock.stop();
console.log(JSON.stringify({{before,after,unsubscribed,subscriber:subscriber===null}}));
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": "0", "after": "2", "unsubscribed": True, "subscriber": True,
}
def test_progressive_mobile_dock_is_shipped_before_optional_workspace_hydration():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/progressive-mobile-dock.js"></script>' in index
assert index.index('static/progressive-my-work.js') < index.index('static/progressive-mobile-dock.js')
assert index.index('static/progressive-mobile-dock.js') < index.index('static/dashboard.js')
assert "window.stackchainProgressiveMobileDock?.handoff?.()" in dashboard
assert "window.stackchainProgressiveMobileDock?.stop?.()" in dashboard
assert "mobileTaskDock.select(progressiveMobileDockHandoff.lastTask)" in dashboard
assert "progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open" in dashboard
assert '[data-progressive-loading="true"]::after' in css
assert "content:'Still loading · available after workspace starts'" in css
def test_progressive_my_work_exposes_counts_and_opens_the_first_selected_queue_item():
harness = f"""
const fs=require('fs'); const vm=require('vm');
function element(extra={{}}) {{return Object.assign({{
innerHTML:'',textContent:'',hidden:true,dataset:{{}},listeners:{{}},focusCount:0,
addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(){{}},
setAttribute(){{}},removeAttribute(){{}},focus(){{this.focusCount+=1;}},
}},extra);}}
const list=element(); const status=element(); const detail=element();
const title=element(); const meta=element(); const reason=element(); const close=element(); const link=element();
const elements={{'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':detail,
'#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta,
'#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close,
'#open-progressive-work-gitea':link}};
const buttons=['all','filed','authored','attention'].map(name=>element({{dataset:{{workFilter:name}}}}));
const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>buttons}};
const lifecycleTarget=element();
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(PROGRESSIVE_MY_WORK))},'utf8'),context);
const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{
user:{{login:'timmy'}},
issues:[{{number:7,title:'Filed issue',repository:'stackchain/dashboard',work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}],
pull_requests:[{{number:8,title:'Authored PR',repository:'stackchain/dashboard',work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}],
}})}});
(async()=>{{
let updates=0; const unsubscribe=flow.subscribe(()=>{{updates+=1;}});
await flow.start(); const counts=flow.counts();
const selected=flow.selectQueue('filed',{{openFirst:true}}); const handoff=flow.handoff();
unsubscribe(); flow.selectQueue('authored');
console.log(JSON.stringify({{counts,updates,selected,detailHidden:detail.hidden,title:title.textContent,handoff}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state["counts"] == {"all": 2, "filed": 1, "authored": 1, "attention": 0, "update": 0, "review": 0}
assert state["updates"] == 2
assert state["selected"] == "opened"
assert state["detailHidden"] is False
assert state["title"] == "Filed issue"
assert state["handoff"]["selectedFilter"] == "filed"
assert state["handoff"]["openWork"]["key"] == "stackchain/dashboard#7"

View File

@ -4,159 +4,8 @@ from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
LIVE_SNAPSHOT = Path(__file__).parents[1] / "frontend" / "progressive-live-snapshot.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
def test_progressive_live_snapshot_coalesces_concurrent_cold_launch_consumers():
harness = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
let calls=0, resolveRequest;
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:()=>{{
calls += 1;
return new Promise(resolve=>{{resolveRequest=resolve;}});
}}}});
(async()=>{{
const myWork=broker.acquire();
const humanGates=broker.acquire();
await Promise.resolve();
const callsWhilePending=calls;
const snapshot={{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}};
resolveRequest(snapshot);
const [first,second]=await Promise.all([myWork,humanGates]);
const cached=await broker.acquire();
process.stdout.write(JSON.stringify({{
callsWhilePending,calls,same:first===second && second===cached,
identity:broker.identity(),snapshot:broker.snapshot(),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"callsWhilePending": 1,
"calls": 1,
"same": True,
"identity": {"login": "timmy", "accountKey": "7:timmy"},
"snapshot": {
"context": {"user": {"id": 7, "login": "timmy"}},
"events": [],
"notifications": [],
},
}
def test_progressive_live_snapshot_retries_identityless_success_for_identity_consumers():
harness = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
let calls=0;
const responses=[
{{context:null,events:[],notifications:[{{id:1}}]}},
{{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}},
];
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{
calls += 1;
return responses.shift();
}}}});
(async()=>{{
const partial=await broker.acquire();
const first=await Promise.allSettled([
broker.acquire({{requireIdentity:true}}),
broker.acquire({{requireIdentity:true}}),
]);
const retries=[
broker.acquire({{requireIdentity:true}}),
broker.acquire({{requireIdentity:true}}),
];
const recovered=await Promise.all(retries);
process.stdout.write(JSON.stringify({{
calls,
partialNotifications:partial.notifications.length,
first:first.map(result=>result.status),
same:recovered[0]===recovered[1],
identity:broker.identity(),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 2,
"partialNotifications": 1,
"first": ["rejected", "rejected"],
"same": True,
"identity": {"login": "timmy", "accountKey": "7:timmy"},
}
def test_progressive_live_snapshot_retries_after_a_failed_shared_flight():
harness = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
let calls=0;
const snapshot={{context:{{user:{{login:'timmy'}}}},events:[],notifications:[]}};
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{
calls += 1;
if(calls===1) throw new Error('temporary outage');
return snapshot;
}}}});
(async()=>{{
const first=await Promise.allSettled([broker.acquire(),broker.acquire()]);
const recovered=await broker.acquire();
process.stdout.write(JSON.stringify({{
calls,first:first.map(result=>result.status),recovered:recovered===snapshot,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 2,
"first": ["rejected", "rejected"],
"recovered": True,
}
def test_progressive_my_work_reports_reconnecting_for_degraded_context_instead_of_inbox_zero():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const list={{innerHTML:''}}; const status={{textContent:''}};
const document={{
hidden:false,
querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,
querySelectorAll:()=>[],
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
context:null,events:[],notifications:[{{id:1}}],
freshness:{{retry_in_seconds:5,sections:{{
context:{{degraded:true,retry_in_seconds:5}},
events:{{degraded:false,age_seconds:1}},notifications:{{degraded:false,age_seconds:1}},
}}}},
}})}});
(async()=>{{
const started=await flow.start();
process.stdout.write(JSON.stringify({{started,status:status.textContent,html:list.innerHTML,counts:flow.counts()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"started": False,
"status": "Assigned work is reconnecting…",
"html": '<div class="muted">Assigned work is reconnecting…</div>',
"counts": {"all": 0, "filed": 0, "authored": 0, "attention": 0,
"update": 0, "review": 0},
}
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
@ -186,8 +35,7 @@ const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{
state = json.loads(result.stdout)
assert state["status"] == "1 assigned work item ready."
assert "Fix mobile queue" in state["rendered"]
assert 'data-progressive-work-index="0"' in state["rendered"]
assert "href=" not in state["rendered"]
assert 'href="https://forge.example/issues/7"' in state["rendered"]
assert state["pressed"] == ["false", "true"]
@ -444,7 +292,7 @@ const flow=context.module.exports({{
"reconnecting": "Assigned work is reconnecting…",
"attempts": 2,
"status": "1 assigned work item ready.",
"html": '<article class="my-work-card progressive-my-work-card"><button class="my-work-card-main" type="button" data-progressive-work-index="0"><strong>Recovered assignment</strong><span class="small">stackchain/dashboard#9 · Assigned to you</span></button></article>',
"html": '<article class="my-work-card progressive-my-work-card"><a class="my-work-card-main" href="https://forge.example/issues/9"><strong>Recovered assignment</strong><span class="small">stackchain/dashboard#9 · Assigned to you</span></a></article>',
}
@ -515,92 +363,5 @@ const flow=context.module.exports({{
"hasSignal": True,
}
assert state["latestTitle"] == "New assignment"
assert state["removed"] == ["keydown", "online", "visibilitychange"]
assert state["removed"] == ["online", "visibilitychange"]
assert state["status"] == "1 assigned work item ready."
def test_progressive_card_opens_safe_in_app_detail_and_hands_it_off_once():
harness = f"""
const fs=require('fs'); const vm=require('vm');
function element(extra={{}}) {{
return Object.assign({{
hidden:false,textContent:'',attrs:{{}},listeners:{{}},focusCount:0,
addEventListener(name,cb){{this.listeners[name]=cb;}},
removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}},
setAttribute(name,value){{this.attrs[name]=String(value);}},
removeAttribute(name){{delete this.attrs[name];}},
focus(){{this.focusCount += 1;}},
}},extra);
}}
const list=element({{innerHTML:''}}); const status=element();
const sheet=element({{hidden:true}}); const title=element(); const meta=element(); const reason=element();
const close=element(); const gitea=element({{hidden:true,href:''}});
const elements={{
'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':sheet,
'#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta,
'#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close,
'#open-progressive-work-gitea':gitea,
}};
const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>[]}};
const lifecycleTarget=element();
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{
user:{{login:'timmy'}},issues:[{{number:7,title:'<img src=x onerror=alert(1)>',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
}})}});
(async()=>{{
await flow.start();
const trigger=element({{dataset:{{progressiveWorkIndex:'0'}},closest:()=>trigger}});
list.listeners.click({{target:trigger,preventDefault(){{}}}});
const opened={{sheetHidden:sheet.hidden,title:title.textContent,meta:meta.textContent,reason:reason.textContent,giteaHidden:gitea.hidden,giteaHref:gitea.href,html:list.innerHTML}};
lifecycleTarget.listeners.keydown({{key:'Escape',preventDefault(){{}}}});
list.listeners.click({{target:trigger,preventDefault(){{}}}});
const first=flow.handoff(); const second=flow.handoff();
console.log(JSON.stringify({{opened,closed:sheet.hidden,focusCount:trigger.focusCount,first,second}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert state["opened"] == {
"sheetHidden": False,
"title": "<img src=x onerror=alert(1)>",
"meta": "stackchain/dashboard#7 · Issue",
"reason": "Assigned to you",
"giteaHidden": False,
"giteaHref": "https://forge.example/issues/7",
"html": '<article class="my-work-card progressive-my-work-card"><button class="my-work-card-main" type="button" data-progressive-work-index="0"><strong>&lt;img src=x onerror=alert(1)&gt;</strong><span class="small">stackchain/dashboard#7 · Assigned to you</span></button></article>',
}
assert state["closed"] is False
assert state["focusCount"] == 1
assert state["first"]["openWork"] == {
"kind": "issue",
"key": "stackchain/dashboard#7",
"number": 7,
"repository": "stackchain/dashboard",
}
assert "openWork" not in state["second"]
def test_progressive_detail_is_mobile_safe_and_adopted_by_hydrated_workspace():
html = INDEX.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="progressive-work-detail" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="progressive-work-detail-title" hidden' in html
assert 'id="close-progressive-work-detail"' in html
assert 'id="open-progressive-work-gitea"' in html
assert ".progressive-work-detail[hidden] { display:none; }" in css
assert "max-height:100dvh" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-top)" in css
assert "env(safe-area-inset-bottom)" in css
assert ".progressive-work-detail-panel button, .progressive-work-detail-panel .button-link" in css
assert "min-height:44px" in css
assert "@media(max-width:320px)" in css
assert "progressiveWorkHandoff?.openWork" in dashboard
assert "openRoutedWork(progressiveItem, null)" in dashboard
assert "qs('#progressive-work-detail').hidden = true" in dashboard

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, *, principal_id, target):
def reserve(self, kind, *, 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, *, principal_id, target):
def reserve(self, kind, *, 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, *, principal_id, target):
def reserve(self, kind, *, 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, *, principal_id, target):
def reserve(self, kind, *, 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, *, principal_id, target):
def reserve(self, kind, *, target):
lifecycle.append(("reserve", kind, target))
return "rollback-operation"

View File

@ -34,11 +34,6 @@ const followingControl = {
addEventListener:(_name, callback) => state.followingChange = callback,
};
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
const humanGateControl = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.humanGateChange = callback,
};
const humanGateStatus = {set textContent(value) { state.humanGateText = value; }, get textContent() { return state.humanGateText; }};
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
@ -57,7 +52,6 @@ const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
humanGateControl, humanGateStatus,
quietControl, quietStart, quietEnd, quietStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
@ -125,31 +119,6 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
assert "followingStatus:qs('#push-following-status')" in dashboard
def test_human_gate_alert_toggle_is_opt_in_and_wired_for_touch_settings():
result = run_scenario("""
state.server = {available:true,subscribed:true,human_gates_enabled:false,following_enabled:true,public_key:'AQID'};
state.current = existing;
await feature.init();
humanGateControl.checked = true;
await state.humanGateChange();
process.stdout.write(JSON.stringify({requests:state.requests, checked:humanGateControl.checked, following:followingControl.checked, text:state.humanGateText}));
""")
assert result["checked"] is True
assert result["following"] is True
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/human-gates", "PUT"]
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
assert result["text"] == "Human Gate decision alerts enabled for this device."
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'for="push-human-gates"' in index
assert 'id="push-human-gates" type="checkbox"' in index
assert 'id="push-human-gates-status" role="status" aria-live="polite"' in index
assert "humanGateControl:qs('#push-human-gates')" in dashboard
assert "humanGateStatus:qs('#push-human-gates-status')" in dashboard
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
result = run_scenario("""
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};

View File

@ -63,59 +63,6 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
assert store.following_preferences("session-b") == {"enabled": False}
@pytest.mark.anyio
async def test_human_gate_dispatch_is_opt_in_private_deduplicated_and_session_bound(tmp_path):
dispatch = getattr(__import__("src.push_notifications", fromlist=["dispatch_human_gate_changes"]), "dispatch_human_gate_changes", None)
assert callable(dispatch), "Human Gate push dispatcher is missing"
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("active", "revoked", "disabled"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_human_gate_preferences("active", enabled=True)
store.set_human_gate_preferences("revoked", enabled=True)
sent = []
pending_count = 2
async def pending_gates():
return {"complete": True, "count": pending_count, "items": [{
"title": "Secret launch", "artifact_url": "https://secret.example/token",
"candidate_hash": "private-hash",
}]}
async def send(subscription, payload):
sent.append((subscription["endpoint"], json.loads(payload)))
async def statuses(session_ids):
return {item: ("active" if item == "active" else "revoked") for item in session_ids}
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch(
store, configuration, pending_gates, send, session_statuses=statuses
) == 1
assert sent == [("https://push.example/active", {
"title": "2 release decisions are waiting",
"body": "Open Human Gates to review them.",
"route": "#/my-work/human-gates",
"tag": "stackchain-human-gates-2",
"human_gate_count": 2,
})]
assert "secret" not in json.dumps(sent).lower()
assert "private-hash" not in json.dumps(sent).lower()
assert await dispatch(
store, configuration, pending_gates, send, session_statuses=statuses
) == 0
pending_count = 0
assert await dispatch(
store, configuration, pending_gates, send, session_statuses=statuses
) == 0
assert store.human_gate_notification_devices()[0].delivered_count == 0
assert len(sent) == 1
assert store.subscription_for_session("revoked") is None
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
@ -286,31 +233,6 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
assert store.start_day_preferences("session-a")["enabled"] is False
@pytest.mark.anyio
async def test_authenticated_device_controls_human_gate_alerts_independently(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
async def management_id(_session):
return "session-a"
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload_type = getattr(main, "HumanGateNotificationPayload", None)
endpoint = getattr(main, "update_human_gate_notifications", None)
assert payload_type is not None and callable(endpoint), "Human Gate push preference API is missing"
result = await endpoint(payload_type(enabled=True), request)
assert result == {"human_gates_enabled": True}
assert (await main.push_status(request))["human_gates_enabled"] is True
assert store.following_preferences("session-a")["enabled"] is False
@pytest.mark.anyio
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@ -777,7 +699,6 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
@ -800,8 +721,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def no_wait(_seconds):
nonlocal sleeps
sleeps += 1
if sleeps <= 5:
if sleeps == 5:
if sleeps <= 4:
if sleeps == 4:
first_tick.set()
await first_tick.wait()
else:
@ -820,20 +741,16 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def dispatch_following(*_args, **_kwargs):
calls.append("following")
async def dispatch_human_gates(*_args, **_kwargs):
calls.append("human-gates")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_human_gates)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
assert sorted(calls) == ["deadline", "following", "human-gates", "start-day", "unread"]
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
@pytest.mark.anyio
@ -859,7 +776,6 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
@ -886,7 +802,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
await main._push_poll_loop()
assert sorted(intervals) == [30.0, 30.0, 30.0, 600.0, 600.0]
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
@pytest.mark.anyio
@ -1656,7 +1572,6 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_timezone": "UTC",
"start_day_reminder_hour": 9,
"following_enabled": False,
"human_gates_enabled": False,
"quiet_hours_enabled": False,
"quiet_hours_start": "22:00",
"quiet_hours_end": "07:00",
@ -1985,21 +1900,3 @@ 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

View File

@ -1,75 +0,0 @@
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"

View File

@ -1,106 +0,0 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_account_scoped(
monkeypatch, tmp_path
):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
active_login = "Timmy"
async def user():
return {"id": 1, "login": active_login}
monkeypatch.setattr(main, "current_user", user)
entry = {
"kind": "issue",
"repository": "stackchain/dashboard",
"number": 1475,
"title": "Sync recent work",
"route": "#/my-work/issue/stackchain/dashboard/1475",
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
forbidden = await client.post("/api/v1/recent-work", json=entry)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
saved = await client.post("/api/v1/recent-work", json=entry, headers=headers)
fetched = await client.get("/api/v1/recent-work")
active_login = "Alexander"
other_account = await client.get("/api/v1/recent-work")
assert forbidden.status_code == 403
assert saved.status_code == 200
assert saved.json() == {"items": [entry], "pinned": []}
assert fetched.json() == saved.json()
assert fetched.headers["cache-control"] == "no-store"
assert other_account.json() == {"items": [], "pinned": []}
@pytest.mark.anyio
async def test_recent_work_pin_api_is_csrf_protected_and_account_scoped(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
active_login = "Timmy"
async def user():
return {"id": 1, "login": active_login}
monkeypatch.setattr(main, "current_user", user)
entry = {
"kind": "pull",
"repository": "stackchain/dashboard",
"number": 1476,
"title": "Sync recent work",
"route": "#/my-work/pull/stackchain/dashboard/1476",
}
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
forbidden = await client.put("/api/v1/recent-work/pin", json=entry)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
pinned = await client.put("/api/v1/recent-work/pin", json=entry, headers=headers)
active_login = "Alexander"
isolated = await client.get("/api/v1/recent-work")
active_login = "Timmy"
unpinned = await client.request(
"DELETE",
"/api/v1/recent-work/pin",
json={"route": entry["route"]},
headers=headers,
)
assert forbidden.status_code == 403
assert pinned.status_code == 200
assert pinned.json() == {"items": [], "pinned": [entry]}
assert isolated.json() == {"items": [], "pinned": []}
assert unpinned.status_code == 200
assert unpinned.json() == {"items": [], "pinned": []}

View File

@ -1,89 +0,0 @@
import sqlite3
from src.recent_work_store import RecentWorkStore
def item(number: int, *, title: str | None = None) -> dict:
return {
"kind": "issue",
"repository": "stackchain/dashboard",
"number": number,
"title": title or f"Issue {number}",
"route": f"#/my-work/issue/stackchain/dashboard/{number}",
}
def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_path):
database = tmp_path / "recent-work.sqlite3"
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
for number in range(1, 7):
store.record(" Timmy ", item(number))
expected = store.record("timmy", item(3, title="Issue 3 updated"))
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
assert store.get("alexander") == {"items": [], "pinned": []}
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT items FROM recent_work WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v1:")
assert "Issue 3 updated" not in payload
assert "#/my-work/issue" not in payload
def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path):
database = tmp_path / "recent-work.sqlite3"
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
store.record("timmy", item(1))
pinned = store.pin("timmy", item(1))
for number in range(2, 8):
store.record("timmy", item(number))
assert pinned["pinned"] == [item(1)]
assert [entry["number"] for entry in store.get("timmy")["items"]] == [7, 6, 5, 4, 3]
assert store.get("timmy")["pinned"] == [item(1)]
assert store.get("alexander") == {"items": [], "pinned": []}
store.record("timmy", item(1, title="Issue 1 current"))
unpinned = store.unpin("timmy", item(1)["route"])
assert unpinned["pinned"] == []
assert unpinned["items"][0] == item(1, title="Issue 1 current")
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT items FROM recent_work WHERE login = 'timmy'"
).fetchone()[0]
assert "Issue 1" not in payload
assert "#/my-work/issue" not in payload
def test_reopening_pinned_work_promotes_it_for_every_device(tmp_path):
store = RecentWorkStore(
tmp_path / "recent-work.sqlite3",
encryption_key=b"r" * 32,
)
for number in range(1, 5):
store.pin("timmy", item(number))
reopened = store.record("timmy", item(1, title="Issue 1 current"))
assert [entry["number"] for entry in reopened["pinned"]] == [1, 4, 3, 2]
assert reopened["pinned"][0]["title"] == "Issue 1 current"
assert store.get("timmy")["pinned"] == reopened["pinned"]
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
invalid = item(1)
invalid["route"] = "https://attacker.example/"
try:
store.record("timmy", invalid)
except ValueError as error:
assert str(error) == "recent work item is invalid"
else:
raise AssertionError("invalid route was accepted")

View File

@ -7,8 +7,6 @@ import sys
from pathlib import Path
from src.completed_filed_review_store import CompletedFiledReviewStore
from src.queue_priority_store import DEFAULT_QUEUE_ORDER, QueuePriorityStore
from src.recent_work_store import RecentWorkStore
from src.saved_search_store import SavedSearchStore
@ -113,59 +111,3 @@ def test_rotation_command_rewraps_completed_filed_history_without_printing_it(tm
assert CompletedFiledReviewStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == {"receipts": [private_receipt]}
def test_rotation_command_rewraps_mobile_queue_priority(tmp_path):
state = tmp_path / "state"
path = state / "queue-priority.sqlite3"
preferred = list(DEFAULT_QUEUE_ORDER)
preferred.remove("following")
preferred.insert(1, "following")
expected = QueuePriorityStore(path, encryption_key=b"o" * 32).replace(
"timmy", 0, preferred
)
completed = run_rotation(state)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout)["queue-priority"] == {
"current": 0, "failed": 0, "migrated": 1, "total": 1
}
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT queue_order FROM queue_priorities WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v2:next:")
assert QueuePriorityStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == expected
def test_rotation_command_rewraps_recent_work_without_printing_titles(tmp_path):
state = tmp_path / "state"
path = state / "recent-work.sqlite3"
private_item = {
"kind": "issue",
"repository": "private/canary",
"number": 1475,
"title": "Secret release investigation",
"route": "#/my-work/issue/private/canary/1475",
}
expected = RecentWorkStore(path, encryption_key=b"o" * 32).record("timmy", private_item)
completed = run_rotation(state)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout)["recent-work"] == {
"current": 0, "failed": 0, "migrated": 1, "total": 1
}
assert private_item["title"] not in completed.stdout
assert "timmy" not in completed.stdout
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT items FROM recent_work WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v2:next:")
assert RecentWorkStore(
path, encryption_key=({"next": b"n" * 32}, "next")
).get("timmy") == expected

View File

@ -66,32 +66,6 @@ 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)
@ -184,7 +158,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(principal_id=42) == []
assert main._passkey_store().all() == []
@pytest.mark.anyio
@ -237,7 +211,7 @@ async def test_passkey_enrollment_registry_failure_discards_reserved_event(
events = SecurityEventStore(
security_access / "security.sqlite3", clock=lambda: 0
).list(principal_id=42, limit=10).events
).list(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)
@ -622,8 +596,7 @@ async def test_comment_deletion_journal_tracks_failed_and_confirmed_outcomes(
return {"id": 42, "deleted": True}
class Journal:
def reserve(self, kind, *, principal_id, target):
assert principal_id == 42
def reserve(self, kind, *, target):
journal_calls.append(("reserve", kind, target))
return "operation-42"
@ -900,7 +873,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(principal_id=42, limit=10).events
).list(limit=10).events
assert signed_out.status_code == 200
assert [(event.kind, event.device_label) for event in events[:2]] == [
("sign_out", None),
@ -1001,7 +974,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(principal_id=42, limit=10).events
).list(limit=10).events
assert response.status_code == 200
assert events[0].kind == "all_sessions_revoked"
assert events[0].target == "all_devices"

View File

@ -18,12 +18,6 @@ def test_release_rollback_has_a_specific_security_activity_label():
assert "release_rollback_prepared: 'Release rollback prepared'" in source
def test_ci_job_retry_has_a_specific_security_activity_label():
source = SECURITY_CENTER.read_text()
assert "ci_job_retried: 'CI job retried'" in source
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});

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", principal_id=42, method="passkey", target="private/repo#42")
store.record("issue_closed", method="passkey", target="private/repo#42")
with sqlite3.connect(store.path) as connection:
columns = {
@ -22,7 +22,6 @@ def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
assert columns == {
"id",
"payload",
"principal_id",
"created_at",
"status",
"operation_id",
@ -39,7 +38,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(principal_id=42, **canaries)
store.record(**canaries)
with sqlite3.connect(path) as connection:
payload = connection.execute(
@ -49,13 +48,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(principal_id=42, limit=10).events[0]
event = reopened.list(limit=10).events[0]
assert (event.kind, event.method, event.device_label, event.target) == tuple(
canaries.values()
)
def test_legacy_security_events_migrate_encrypted_but_remain_unattributed(tmp_path):
def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_path):
path = tmp_path / "security.sqlite3"
with sqlite3.connect(path) as connection:
connection.executescript(
@ -81,8 +80,17 @@ def test_legacy_security_events_migrate_encrypted_but_remain_unattributed(tmp_pa
)
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
assert store.list(principal_id=42, limit=10).events == []
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")
]
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"
@ -97,12 +105,12 @@ def test_legacy_security_events_migrate_encrypted_but_remain_unattributed(tmp_pa
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", principal_id=42, target="private/repo#42")
store.record("issue_closed", target="private/repo#42")
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
SecurityEventStore(
path, clock=lambda: 1_001, encryption_key=b"x" * 32
).list(principal_id=42, limit=10)
).list(limit=10)
with sqlite3.connect(path) as connection:
payload = connection.execute(
@ -114,7 +122,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(principal_id=42, limit=10)
store.list(limit=10)
def test_missing_security_activity_encryption_key_fails_with_store_error(
@ -132,21 +140,20 @@ 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", principal_id=42, device_label="Old phone", target="device")
store.record("device_revoked", device_label="Old phone", target="device")
page = store.list(principal_id=42, limit=1)
page = store.list(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(principal_id=42, limit=10, cursor=page.next_cursor)
older = store.list(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)
@ -155,29 +162,10 @@ 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", "principal_id", "created_at", "status", "operation_id",
"id", "payload", "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(
@ -188,32 +176,32 @@ def test_security_event_retention_prunes_age_and_count(tmp_path):
)
for index in range(4):
now[0] = index
store.record("sign_in", principal_id=42, method="token", device_label=f"Device {index}")
store.record("sign_in", method="token", device_label=f"Device {index}")
assert [event.device_label for event in store.list(principal_id=42, limit=10).events] == [
assert [event.device_label for event in store.list(limit=10).events] == [
"Device 3", "Device 2", "Device 1"
]
now[0] = 20
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"]
store.record("sign_out", device_label="Current")
assert [event.kind for event in store.list(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", principal_id=42, target="stackchain/api#7")
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
pending = SecurityEventStore(
tmp_path / "security.sqlite3", clock=lambda: 1_001
).list(principal_id=42, limit=10).events
).list(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(principal_id=42, limit=10).events
completed = store.list(limit=10).events
assert [(event.kind, event.target, event.status) for event in completed] == [
("issue_closed", "stackchain/api#7", "completed")
]
@ -221,8 +209,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", principal_id=42, target="stackchain/api#7")
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
store.discard(operation_id)
assert store.list(principal_id=42, limit=10).events == []
assert store.list(limit=10).events == []

View File

@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0, 'human-gates':0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@ -83,7 +83,7 @@ const context = {{
set: async enabled => {{ state.badgeEnabled = enabled; }},
getCounts: async () => ({{...state.badgeCounts}}),
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0, 'human-gates':0}}; }},
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
@ -186,17 +186,10 @@ async function dispatchPush(payload) {{
return json.loads(completed.stdout)
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
source = WORKER.read_text()
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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@ -204,20 +197,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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v150" in source
assert "stackchain-dashboard-shell-v139" 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-v150" in source
assert "stackchain-dashboard-shell-v139" 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 +219,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-v150" in source
assert "stackchain-dashboard-shell-v139" 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 +228,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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@ -243,14 +236,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-v150" in source
assert "stackchain-dashboard-shell-v139" 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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -258,7 +251,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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@ -266,7 +259,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-v150" in source
assert "stackchain-dashboard-shell-v139" 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 +269,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-v150" in source
assert "stackchain-dashboard-shell-v139" 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-v150" in source
assert "stackchain-dashboard-shell-v139" 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 +285,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-v150" in source
assert "stackchain-dashboard-shell-v139" 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-v150" in source
assert "stackchain-dashboard-shell-v139" 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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/update-ownership.js'" in source
@ -935,35 +928,6 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
assert "must-not-render" not in json.dumps(result["notifications"])
def test_human_gate_push_is_count_only_and_tap_opens_canonical_mobile_review_route():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchPush({
title:'must-not-render', body:'secret candidate must-not-render',
tag:'stackchain-human-gates-2', route:'#/my-work/human-gates',
human_gate_count:2, artifact_url:'https://secret.example/token',
});
await dispatchNotificationClick('#/my-work/human-gates', '', null, 'stackchain-human-gates-2');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [{
"title": "2 release decisions are waiting",
"options": {
"body": "Open Human Gates to review them.",
"tag": "stackchain-human-gates-2",
"data": {"route": "#/my-work/human-gates"},
},
}]
assert result["opened"] == [
"https://forge.example/dashboard/#/my-work/human-gates"
]
assert result["appBadges"] == [2]
assert "secret" not in json.dumps(result["notifications"])
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
result = run_worker_scenario(
"""
@ -1052,7 +1016,7 @@ def test_background_badge_combines_following_and_updates_without_channel_overwri
)
assert result["appBadges"] == [2, 5, 4]
assert result["badgeCounts"] == {"updates": 3, "following": 1, "human-gates": 0}
assert result["badgeCounts"] == {"updates": 3, "following": 1}
assert result["clearedAppBadges"] == 0
@ -1068,7 +1032,7 @@ def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
"""
)
assert result["badgeCounts"] == {"updates": 3, "following": 2, "human-gates": 0}
assert result["badgeCounts"] == {"updates": 3, "following": 2}
assert result["appBadges"] == [5]
@ -1083,7 +1047,7 @@ def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push()
"""
)
assert result["badgeCounts"] == {"updates": 2, "following": 0, "human-gates": 0}
assert result["badgeCounts"] == {"updates": 2, "following": 0}
assert result["appBadges"] == [2, 2]
@ -1390,7 +1354,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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/queue-today.js'" in source
@ -1409,8 +1373,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/manifest.webmanifest",
"/dashboard/static/dashboard.css",
"/dashboard/static/dashboard.js",
"/dashboard/static/human-gates.js",
"/dashboard/static/progressive-human-gates.js",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
@ -1446,9 +1408,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/progressive-live-snapshot.js",
"/dashboard/static/progressive-my-work.js",
"/dashboard/static/progressive-mobile-dock.js",
"/dashboard/static/progressive-capture.js",
"/dashboard/static/agenda-replan.js",
"/dashboard/static/agenda-calendar.js",
@ -1527,8 +1487,6 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-first-task.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-recent-work.js",
"/dashboard/static/mobile-queue-priority.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js",
"/dashboard/static/mobile-start-day.js",

View File

@ -201,38 +201,6 @@ 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])
@ -261,14 +229,10 @@ 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", principal_id=42
)
store.activate(
"laptop-session-secret", 3_000, device_label="Work laptop", principal_id=42
)
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
store.activate("laptop-session-secret", 3_000, device_label="Work laptop")
devices = store.list_active("phone-session-secret", principal_id=42)
devices = store.list_active("phone-session-secret")
assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"]
assert [device.current for device in devices] == [False, True]
@ -279,13 +243,9 @@ 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", 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"
)
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")
assert store.revoke_managed(phone.management_id) is True
assert store.is_active("phone", 2_000) is False
@ -306,8 +266,9 @@ 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", principal_id=42)
assert devices == []
devices = store.list_active("existing-session")
assert len(devices) == 2
assert next(device for device in devices if device.current).device_label == "Existing device"
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(tmp_path):
@ -427,11 +388,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", principal_id=42)
store.activate("phone-session", 2_000, device_label="Phone")
grant = store.mint_step_up(
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
)
phone = store.list_active("phone-session", principal_id=42)[0]
phone = store.list_active("phone-session")[0]
assert store.revoke_managed(phone.management_id) is True
assert store.consume_step_up(

View File

@ -209,7 +209,6 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)

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-v150';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v139';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker

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-v150" in source
assert "stackchain-dashboard-shell-v139" in source
assert "BASE + 'static/today-sync.js'" in source

View File

@ -326,9 +326,8 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
assert result["remaining"] == 1
def run_mounted_confirmation(*, fail_move: bool = False, fail_refresh: bool = False) -> dict:
def run_mounted_confirmation(*, fail_move: bool = False) -> dict:
failure = "true" if fail_move else "false"
refresh_failure = "true" if fail_refresh else "false"
scenario = """
const mount=require('./frontend/today-week-reschedule.js').mount;
class Element {
@ -362,9 +361,7 @@ const week={load:async()=>weekState,review:()=>({days:[{
}]}),adopt(){}};
const api=async(url)=>url==='api/v1/today' ? today : move;
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today,
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{
if(__FAIL_REFRESH__)throw new Error('Refresh unavailable.');
},warm(){},
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{},warm(){},
continueToday:async()=>{},announce(){},schedule:callback=>callback()});
await selectors['[data-work-session-reschedule-week]'].emit('click');
await selectors['#today-week-reschedule-days'].children[0].emit('click');
@ -379,11 +376,7 @@ if(__FAIL_MOVE__){
await confirming;
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open}));
"""
return run_controller(
scenario.replace("__FAIL_MOVE__", failure).replace(
"__FAIL_REFRESH__", refresh_failure
)
)
return run_controller(scenario.replace("__FAIL_MOVE__", failure))
def test_reschedule_dialog_closes_as_soon_as_a_valid_move_is_confirmed():
@ -400,13 +393,6 @@ def test_reschedule_dialog_reopens_with_retry_state_when_confirm_fails():
}
def test_reschedule_dialog_stays_closed_when_refresh_fails_after_confirm_succeeds():
assert run_mounted_confirmation(fail_refresh=True) == {
"openWhileSaving": False,
"openAfterSettled": False,
}
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
index = INDEX.read_text()
css = CSS.read_text()

View File

@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
return json.loads(completed.stdout)
def test_workspace_bootstrap_demand_loads_optional_features_after_work_core():
def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
result = run_bootstrap("""
const status={textContent:''};
const document={
@ -33,14 +33,11 @@ const document={
};
const requested=[];
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
const lifecycle=await loadWorkspace({document,createLoader});
const before=requested.slice();
await lifecycle.hydrateWorkspace?.();
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
await loadWorkspace({document,createLoader});
console.log(JSON.stringify({requested,status:status.textContent}));
""")
assert result == {
"before": ["work-core:feature-work-core-123.js"],
"after": [
"requested": [
"work-core:feature-work-core-123.js",
"today-timer:feature-workspace-abc.js",
"planning:feature-planning-def.js",
@ -49,65 +46,7 @@ console.log(JSON.stringify({before,after:requested,status:status.textContent}));
}
def test_workspace_bootstrap_hydrates_once_and_replays_dependent_mobile_action():
result = run_bootstrap("""
const listeners={}; const requested=[];
const document={
querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
return match ? {content:'feature-' + match[1] + '.js'} : null;
},
addEventListener(name,callback,capture){listeners[name]={callback,capture};},
removeEventListener(name,callback,capture){
if(listeners[name]?.callback===callback && listeners[name]?.capture===capture) delete listeners[name];
},
};
const createLoader=()=>({load:async name=>{
requested.push(name);
if(name==='today-timer') setImmediate(()=>{
actionReady=true;
lifecycle.markWorkspaceReady?.();
});
}});
let lifecycle=await loadWorkspace({document,createLoader});
let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false;
const target={
closest(selector){return selector.includes(':not([data-mobile-task="work"])') ? null : this;},
click(){replayed++; replayedReady=actionReady;},
};
await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}});
console.log(JSON.stringify({requested,prevented,stopped,replayed,replayedReady,listening:Boolean(listeners.click)}));
""")
assert result == {
"requested": ["work-core", "today-timer", "planning"],
"prevented": 1,
"stopped": 1,
"replayed": 1,
"replayedReady": True,
"listening": False,
}
def test_workspace_bootstrap_hydrates_immediately_for_deep_link():
result = run_bootstrap("""
const requested=[];
const document={
querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
return match ? {content:'feature-' + match[1] + '.js'} : null;
},
addEventListener(){},
};
const window={location:{hash:'#/my-work/today'},addEventListener(){},removeEventListener(){}};
const createLoader=()=>({load:async name=>{requested.push(name);}});
const lifecycle=await loadWorkspace({document,window,createLoader});
await lifecycle.deepLinkReady;
console.log(JSON.stringify({requested}));
""")
assert result == {"requested": ["work-core", "today-timer", "planning"]}
def test_workspace_bootstrap_returns_after_work_core_before_optional_features_hydrate():
def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate():
result = run_bootstrap("""
const document={querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
@ -121,13 +60,12 @@ const createLoader=()=>({load:name=>{
}});
const lifecycle=await loadWorkspace({document,createLoader});
const returned=requested.slice();
const hydration=lifecycle.hydrateWorkspace();
releases['today-timer'](); releases.planning();
await hydration;
await lifecycle.optionalReady;
console.log(JSON.stringify({returned,settled:requested}));
""")
assert result == {
"returned": ["work-core"],
"returned": ["work-core", "today-timer", "planning"],
"settled": ["work-core", "today-timer", "planning"],
}
@ -143,8 +81,7 @@ const document={querySelector(selector) {
}};
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
const schedule=callback=>{callback();};
const lifecycle=await loadWorkspace({document,createLoader,schedule});
await lifecycle.hydrateWorkspace();
await loadWorkspace({document,createLoader,schedule});
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {"attempts": 4, "status": "", "retryHidden": True}
@ -170,7 +107,7 @@ await Promise.all([first,second,loading]);
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {
"attempts": 3,
"attempts": 5,
"reloads": 0,
"offered": {
"hidden": False,
@ -214,7 +151,7 @@ await loading;
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
""")
assert result == {
"attempts": 3,
"attempts": 5,
"waiting": True,
"reloads": 0,
"status": "",