Compare commits

...
Author SHA1 Message Date
timmy ebb7a78cab feat: finish onboarding with active Today (Closes #1144)
CI / lint (pull_request) Successful in 2m48s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 4m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:51:15 +00:00
rockachopa b4a5401b22 Merge pull request 'Preserve timed break across reload' (#1143)
CI / lint (push) Successful in 3m3s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 4m2s
CI / release-candidate (push) Successful in 6s
Closes #1142
2026-08-19 18:32:30 +00:00
timmy 870edda123 Preserve timed break when active work reopens
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 18:08:35 +00:00
rockachopa 156d4b0ffc Merge pull request 'Guide new mobile operators to their first task' (#1141) from timmy/1140-first-task-activation into main
CI / lint (push) Successful in 2m54s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 3m36s
CI / release-candidate (push) Has been skipped
2026-08-19 17:41:31 +00:00
timmy 304e4a8a24 feat: guide first mobile task activation (Closes #1140)
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 17:33:26 +00:00
rockachopa 8731682090 Merge pull request 'Pause Today while checking Live Data Status' (#1139) from timmy/1138-live-data-status-detour into main
CI / lint (push) Successful in 3m13s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 3m29s
CI / release-candidate (push) Successful in 7s
2026-08-19 16:44:43 +00:00
timmy 348f43f1bc feat: pause Today for live data status (Closes #1138)
CI / lint (pull_request) Successful in 3m8s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 16:35:09 +00:00
rockachopa 161563d6f5 Merge pull request 'Contain focus inside mobile Security Center' (#1137)
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m25s
CI / release-candidate (push) Successful in 7s
Closes #1136
2026-08-19 15:45:00 +00:00
timmy 931c82361b feat: contain Security Center focus (Closes #1136)
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 15:34:47 +00:00
rockachopa 61b332e814 Merge pull request 'Contain focus inside mobile Device Setup' (#1135) from timmy/1134-device-setup-focus-containment into main
CI / lint (push) Successful in 2m59s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m21s
CI / release-candidate (push) Successful in 7s
2026-08-19 14:44:26 +00:00
timmy 4b3e53f4d0 feat: contain mobile Device Setup focus (Closes #1134)
CI / lint (pull_request) Successful in 2m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 14:31:38 +00:00
rockachopa f3c9e89920 Merge pull request 'Recover mobile notification setup after permission is blocked' (#1133) from timmy/1132-notification-permission-recovery into main
CI / lint (push) Successful in 2m58s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 3m23s
CI / release-candidate (push) Has been skipped
2026-08-19 13:43:48 +00:00
timmy 1248647aa4 feat: recover blocked notification setup (Closes #1132)
CI / lint (pull_request) Successful in 2m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 13:36:20 +00:00
rockachopa b3832d107f Merge pull request 'Post reviewed Today summaries to a Gitea status thread' (#1131) from timmy/1130-post-today-summary into main
CI / lint (push) Successful in 2m56s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m23s
CI / release-candidate (push) Successful in 6s
2026-08-19 12:45:00 +00:00
timmy a0da8e9fb2 feat: post reviewed Today summaries to Gitea (Closes #1130)
CI / lint (pull_request) Successful in 2m51s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 12:36:46 +00:00
timmy 8794200c2a Merge pull request 'Protect private work before mobile sign-out' (#1129) from timmy/1128-safe-sign-out-review into main
CI / lint (push) Successful in 2m55s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 3m20s
CI / release-candidate (push) Successful in 6s
2026-08-19 11:45:23 +00:00
timmy c2526969b1 feat: protect private work before sign out (Closes #1128)
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 11:37:27 +00:00
rockachopa 3b4f6b39a5 Merge pull request 'Encrypt synchronized Saved Searches at rest' (#1127) from timmy/1126-encrypt-saved-searches into main
CI / lint (push) Successful in 2m43s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 3m2s
CI / release-candidate (push) Successful in 6s
2026-08-19 11:03:52 +00:00
timmy a9cf88b3ff security: encrypt synchronized Saved Searches (Closes #1126)
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 3m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:57:01 +00:00
rockachopa 3d4be0a913 Merge pull request 'Recover mobile workspace loading in place' (#1125) from timmy/1124-workspace-load-recovery into main
CI / lint (push) Successful in 2m36s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m58s
CI / release-candidate (push) Successful in 6s
2026-08-19 10:21:01 +00:00
timmy 9252807364 fix: recover mobile workspace loading in place (Closes #1124)
CI / lint (pull_request) Successful in 2m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 10:10:52 +00:00
rockachopa db85bfa5f9 Merge pull request 'Encrypt authored-action idempotency ledger at rest' (#1123)
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m55s
CI / release-candidate (push) Successful in 6s
Closes #1122
2026-08-19 09:35:55 +00:00
timmy 3822ec4536 security: encrypt idempotency ledger at rest (Closes #1122)
CI / lint (pull_request) Successful in 2m49s
CI / build-release (pull_request) Successful in 5s
CI / browser-journey (pull_request) Successful in 2m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:29:16 +00:00
rockachopa b278367e3b Merge pull request 'Verify Web Push delivery health per device' (#1121) from timmy/1120-push-delivery-health into main
CI / lint (push) Successful in 2m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 3m10s
CI / release-candidate (push) Successful in 6s
2026-08-19 09:06:55 +00:00
timmy 13e73bd342 feat: verify per-device Push delivery health (Closes #1120)
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 09:00:06 +00:00
rockachopa 1047f3ecdc Merge pull request 'Encrypt Security activity journal at rest' (#1119) from timmy/1118-encrypt-security-activity into main
CI / lint (push) Successful in 2m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 2m53s
CI / release-candidate (push) Successful in 7s
2026-08-19 07:49:14 +00:00
timmy f09c922c46 test: model admitted blocker receipt deterministically
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:42:46 +00:00
timmy be9ce8ede6 security: remove plaintext journal metadata columns 2026-08-19 07:37:35 +00:00
timmy 7f2681c1c6 security: encrypt Security activity journal (Closes #1118)
CI / lint (pull_request) Successful in 2m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 3m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 07:25:49 +00:00
rockachopa 91fdd958c2 Merge pull request 'Fetch the mobile workspace concurrently with the core runtime' (#1117) from timmy/1116-parallel-workspace-fetch into main
CI / lint (push) Successful in 2m51s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m55s
CI / release-candidate (push) Successful in 6s
2026-08-19 06:52:25 +00:00
timmy b70a2fb607 test: accept queued or delivered blocker receipt
CI / lint (pull_request) Successful in 2m33s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:46:05 +00:00
timmy 950d52d147 perf: parallelize mobile workspace fetch (Closes #1116)
CI / lint (pull_request) Successful in 2m50s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 2m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 06:25:26 +00:00
timmy d054cd23ed Merge pull request 'Encrypt synchronized Later plans at rest' (#1115) from timmy/1114-encrypt-later-state into main
CI / lint (push) Successful in 2m45s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m39s
CI / release-candidate (push) Successful in 6s
2026-08-19 05:38:47 +00:00
timmy f4d8d64b9d security: encrypt synchronized Later state (Closes #1114)
CI / lint (pull_request) Successful in 2m43s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m51s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 05:32:20 +00:00
timmy 9763bb410b Merge pull request 'Encrypt synchronized Today journeys at rest' (#1113) from timmy/1112-encrypt-today-state into main
CI / lint (push) Successful in 2m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m40s
CI / release-candidate (push) Successful in 6s
2026-08-19 04:41:16 +00:00
timmy db3ca83f29 security: encrypt synchronized Today state (Closes #1112)
CI / lint (pull_request) Successful in 2m36s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 04:34:55 +00:00
rockachopa c219f130c8 Merge pull request 'Protect offline work from browser storage eviction' (#1111) from timmy/1110-protect-offline-storage into main
CI / lint (push) Successful in 2m52s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m54s
CI / release-candidate (push) Successful in 7s
2026-08-19 03:43:54 +00:00
timmy dc4f457b21 feat: protect offline work from storage eviction (Closes #1110)
CI / lint (pull_request) Successful in 2m47s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-19 03:36:48 +00:00
rockachopa 9a471b7903 Merge pull request 'Encrypt Web Push subscriptions at rest' (#1109) from timmy/1108-encrypt-push-subscriptions into main
CI / lint (push) Successful in 2m38s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m46s
CI / release-candidate (push) Successful in 6s
2026-08-19 02:41:03 +00:00
62 changed files with 4099 additions and 374 deletions

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate:
runs-on: ubuntu-latest
+31 -11
View File
@@ -193,11 +193,18 @@ filesystem boundary independently of the service umask: the database directory i
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
envelopes authenticated to their store identity (and live generation), so copied databases do not
expose issue bodies, titles, notification metadata, or repository context. Existing plaintext
snapshot rows migrate on their first read without changing freshness, revisions, ordering, or claim
filters. 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. Other private stores
are not encrypted at the application layer. Web Push subscriptions use a third, independent
expose issue bodies, titles, notification metadata, or repository context. The authored-action
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
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 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; missing, wrong, or modified key material returns no saved-view content. Other private stores
are not encrypted at the application layer. Web
Push subscriptions use a third, independent
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
@@ -267,10 +274,13 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Required for worker-shared live and Find Work snapshots. Keep this key
# independent from the Draft key and inject the base64 encoding of exactly 32
# random bytes from a secret manager. Never commit it. Missing, malformed,
# wrong-key, or modified snapshot state fails closed without returning content.
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
# planning and Saved Search state, and the Security activity journal. Keep this key independent
# from the Draft key and inject the
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
# it. Missing, malformed, wrong-key, or modified state fails closed without
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
# migrate atomically on first use without changing logical revisions or journal IDs.
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
# Required for cross-device unfiled Draft sync. The single-key setting remains
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
@@ -384,8 +394,13 @@ place, their live sessions remain valid, and their idle clock starts at migratio
The same sheet includes **Security activity**, a reverse-chronological journal of
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
revocations, issue closures, and pull-request merges. The separate SQLite journal
retains at most 10,000 events for 90 days and stores only bounded device labels and
action targets. It never stores access tokens, cookies, session/CSRF values,
retains at most 10,000 events for 90 days. Its bounded event kind, authentication
method, device label, and action target are sealed at rest with the private-state
encryption key and authenticated against the immutable event ID. Legacy plaintext
journals migrate atomically without changing IDs, timestamps, order, or pending
operations. Missing or wrong key material and modified ciphertext fail closed with
no partial activity response. The journal never stores access tokens, cookies,
session/CSRF values,
credential IDs, public keys, challenges, attestation data, raw network addresses,
request bodies, or comment content. Keep its database on the same class of persistent,
writable storage as the session registry. Before passkey credential creation, session
@@ -453,6 +468,11 @@ is assembled in source order into one content-addressed JavaScript response. Das
HTML and the offline worker reference that exact fingerprint, while the runtime receives
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
Device Setup also checks whether the browser has granted persistent storage without prompting.
Choose **Protect offline work** to request protection from automatic storage-pressure eviction;
Stackchain reports **Protected**, **Best effort**, **Denied**, or an unavailable/retryable state
truthfully. Offline work continues when protection is unavailable or denied, but the browser may
remove best-effort data, so persistence does not replace backups or device security.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
+26 -1
View File
@@ -6,12 +6,22 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.sign-out-review-sheet[hidden] { display:none; }
.sign-out-review-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.sign-out-review-panel h2 { margin:.25rem 0; }
.sign-out-review-warning { color:#fbbf24; }
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.sign-out-review-actions button { min-height:44px; width:100%; }
#confirm-sign-out { border-color:#b45309; }
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.live-data-status-sheet[hidden] { display:none; }
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; }
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.issue-filing-receipt[hidden] { display:none; }
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
@@ -367,8 +377,14 @@ textarea { resize: vertical; min-height: 120px; }
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
.today-summary-destination h3 { margin-top:0; }
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
.today-summary-target { min-height:1.4em; margin-top:8px; }
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-summary-actions button { min-height:44px; width:100%; }
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
@@ -1153,6 +1169,15 @@ textarea { resize: vertical; min-height: 120px; }
.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 { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.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; }
.mobile-first-task::backdrop { background:rgba(3,9,18,.78); }
.mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; }
.mobile-first-task-actions { display:grid; gap:10px; }
.mobile-first-task-actions button { min-height:48px; width:100%; }
.mobile-first-task-actions button:disabled { opacity:.55; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.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; }
+29 -20
View File
@@ -140,6 +140,12 @@
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
qs('#mobile-start-day-action').focus();
}
const mobileFirstTask = createMobileFirstTask({
getLogin: () => confirmedOwnerLogin,
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
isTodayActive: () => workSession.checkpointed(),
});
mobileFirstTask.start();
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
@@ -152,6 +158,8 @@
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
prepareToday: openMobileStartDay,
shouldActivate: () => mobileFirstTask.required(),
openActivation: () => mobileFirstTask.open(),
findWork: () => qs('#find-work').click(),
});
const mobileStartDay = createMobileStartDay({
@@ -1929,7 +1937,10 @@
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
const todaySummaryView = setupTodaySummary({
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
});
todaySummaryView.resume();
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
@@ -3222,6 +3233,7 @@
});
mobileStartDay.render();
mobileTaskDock.updateQueues(counts);
mobileFirstTask.refresh();
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
@@ -7448,24 +7460,7 @@
'Next automatic retry in ' + retrySeconds + 's.' :
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
}
function closeLiveDataStatus() {
liveDataStatusSheet.hidden = true;
liveDataStatusTrigger.setAttribute('aria-expanded', 'false');
liveDataStatusTrigger.focus();
}
liveDataStatusTrigger.addEventListener('click', () => {
renderLiveDataStatus();
liveDataStatusSheet.hidden = false;
liveDataStatusTrigger.setAttribute('aria-expanded', 'true');
qs('#close-live-data-status').focus();
});
qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus);
liveDataStatusSheet.addEventListener('click', event => {
if (event.target === liveDataStatusSheet) closeLiveDataStatus();
});
liveDataStatusSheet.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); }
});
liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
const liveDataRefresh = liveDataStatus.createRefreshController({
button: qs('#refresh-live-data'),
output: qs('#live-data-status-result'),
@@ -7924,6 +7919,7 @@
const controller = createPushNotifications({
control:qs('#push-updates'),
status:qs('#push-update-status'),
testControl:qs('#push-test'),
deadlineControl:qs('#push-deadlines'),
deadlineStatus:qs('#push-deadline-status'),
deadlineHour:qs('#push-deadline-hour'),
@@ -7976,15 +7972,25 @@
isIosSafari:() => isIosDevice && /Safari/.test(navigator.userAgent) && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(navigator.userAgent),
});
installApp.start();
const deviceStorage = createDeviceStorage.mount(document);
await deviceStorage.start();
deviceSetup = createMobileDeviceSetup.mount({
document, installApp, promptStorage:localStorage,
timerView,
offlineAvailable:() => offlineStorageReady,
offlineEnabled:() => offlineWorkStore.enabled(),
enableOffline:() => setOfflineWorkEnabled(true),
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
protectStorage:() => deviceStorage.requestPersistence(),
notificationReadiness:() => pushController?.notificationReadiness()
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
enablePush:async () => {
const controller = await pushControllerReady;
if (!controller) return;
if (controller.notificationReadiness().state === 'blocked') {
await controller.recoverPermission('updates');
return;
}
qs('#push-updates').checked = true;
await controller.change();
},
@@ -7995,10 +8001,13 @@
if (!controller) return;
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
if (controller.deadlineReadiness().state === 'blocked') {
await controller.recoverPermission('deadline');
return;
}
await controller.enableDeadline();
},
});
await createDeviceStorage.mount(document).start();
await deviceSetup.start();
return deviceSetup;
}
+42 -3
View File
@@ -21,6 +21,7 @@
let privateRecordCount = 0;
let inventoryUnavailable = false;
let fullClearArmed = false;
let persistence = { state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable' };
const ownedCaches = async () => (await options.caches?.keys?.() || [])
.filter(name => name.startsWith('stackchain-dashboard-'));
@@ -37,10 +38,48 @@
return Math.round((Number(value) || 0) / (1024 * 1024));
}
async function inspectPersistence() {
if (typeof options.storageManager?.persisted !== 'function' || typeof options.storageManager?.persist !== 'function') {
persistence = {state:'unavailable', detail:'Storage protection is unavailable in this browser.', label:'Unavailable'};
return persistence;
}
try {
const protectedStorage = await options.storageManager.persisted();
persistence = protectedStorage
? {state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.', label:'Protected'}
: {state:'incomplete', detail:'Offline work uses best-effort browser storage and may be removed under storage pressure.', label:'Best effort'};
} catch (_error) {
persistence = {state:'incomplete', detail:'Storage protection could not be checked. Retry to protect offline work.', label:'Check failed'};
}
return persistence;
}
function persistenceReadiness() {
return {state:persistence.state, detail:persistence.detail};
}
async function requestPersistence() {
if (typeof options.storageManager?.persist !== 'function') return persistenceReadiness();
let granted = false;
try { granted = await options.storageManager.persist(); }
catch (_error) {
persistence = {state:'incomplete', detail:'Storage protection request failed. Offline work still uses best-effort storage; retry when ready.', label:'Request failed'};
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Request failed ');
return persistenceReadiness();
}
await refresh();
if (!granted && persistence.state !== 'complete') {
persistence = {state:'incomplete', detail:'The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.', label:'Denied'};
options.detail.textContent = options.detail.textContent.replace(/Storage protection: [^·]+/, 'Storage protection: Denied ');
}
return persistenceReadiness();
}
async function refresh() {
let estimate = null;
try { estimate = await options.storageManager?.estimate?.(); }
catch (_error) { /* Storage estimates are optional. */ }
await inspectPersistence();
options.summary.textContent = estimate?.quota
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
: 'Browser storage usage is unavailable.';
@@ -52,7 +91,7 @@
privateRecordCount = inventory.recordCount;
inventoryUnavailable = inventory.unavailable;
const cacheCount = (await ownedCaches()).length;
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
options.detail.textContent = `Storage protection: ${persistence.label} · ${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
@@ -84,7 +123,7 @@
privateItemCount = 0;
fullClearArmed = false;
options.clearAllButton.textContent = 'Clear all private data';
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
options.detail.textContent = `Storage protection: ${persistence.label} · 0 private browser items · 0 private work records · 0 cached app copies`;
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
} catch (error) {
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
@@ -99,5 +138,5 @@
return refresh();
}
return { refresh, start };
return { refresh, start, persistenceReadiness, requestPersistence };
});
+49 -1
View File
@@ -27,16 +27,29 @@
</div>
</details>
</header>
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
<h2 id="sign-out-review-heading">Review sign out</h2>
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
<div class="sign-out-review-actions">
<button id="cancel-sign-out" type="button">Cancel</button>
<button id="confirm-sign-out" type="button">Sign out</button>
</div>
</section>
</div>
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
<div class="live-data-status-header">
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
</div>
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
<p id="live-data-status-retry" class="small muted"></p>
<div class="live-data-status-actions">
<button id="refresh-live-data" type="button">Refresh now</button>
<button id="return-from-live-data-status" type="button">Return to Today</button>
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
</div>
</section>
@@ -112,6 +125,10 @@
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Protect offline work</strong><p class="small" id="device-setup-protection-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-protection" type="button">Protect</button>
</li>
<li class="device-setup-step">
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
@@ -186,6 +203,7 @@
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
@@ -309,6 +327,7 @@
<div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
@@ -541,7 +560,18 @@
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
<h3>Preview</h3>
<pre id="today-summary-preview" class="today-summary-preview"></pre>
<section class="today-summary-destination" aria-labelledby="today-summary-destination-title">
<h3 id="today-summary-destination-title">Post to Gitea</h3>
<p class="small muted">Enter the exact issue or pull request that should receive this reviewed comment.</p>
<label for="today-summary-destination">Destination</label>
<div class="today-summary-destination-controls">
<input id="today-summary-destination" type="text" inputmode="text" autocomplete="off" maxlength="260" placeholder="owner/repo#42">
<button id="validate-today-summary-destination" type="button">Review destination</button>
</div>
<div id="today-summary-target" class="today-summary-target small" aria-live="polite"></div>
</section>
<div class="today-summary-actions">
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
<button id="share-today-summary" type="button">Share summary</button>
<button id="discard-today-summary" type="button">Discard draft</button>
</div>
@@ -1799,6 +1829,22 @@
</section>
</dialog>
<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">
<section class="mobile-first-task-panel">
<header>
<div><p class="small muted">Welcome to Work</p><h2 id="mobile-first-task-title">Start your first task</h2></div>
<button id="close-mobile-first-task" type="button">Close</button>
</header>
<p class="small">Choose work, start it, then return to Work whenever you want to continue.</p>
<p id="mobile-first-task-status" class="small" role="status" aria-live="polite"></p>
<div class="mobile-first-task-actions">
<button id="mobile-first-task-find" type="button">Find &amp; start</button>
<button id="mobile-first-task-create" type="button">Create &amp; start</button>
<button id="mobile-first-task-setup" type="button">Make this phone work-ready</button>
</div>
</section>
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
@@ -1810,6 +1856,8 @@
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
<script src="static/private-data-registry.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/sign-out-review.js"></script>
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/conversation-action-hydrator.js"></script>
@@ -1901,6 +1949,7 @@
<script src="static/mobile-today-command-bar.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-first-task.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
@@ -1917,7 +1966,6 @@
<script src="static/mobile-insights.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script>
<script src="static/private-data-inventory.js"></script>
<script src="static/private-device-data.js"></script>
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>
+108 -1
View File
@@ -74,5 +74,112 @@
return { run };
}
return { describe, createRefreshController };
function createSheetController(options) {
let ownsDetour = false;
let backgroundState = null;
function focusableControls() {
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
.filter(control => !control.hidden);
}
function containBackground() {
if (backgroundState) return;
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundState) return;
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundState = null;
}
function finishClose() {
if (options.sheet.hidden) return;
options.sheet.hidden = true;
options.trigger.setAttribute('aria-expanded', 'false');
if (options.pausedStatus) options.pausedStatus.hidden = true;
releaseBackground();
if (ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
options.trigger.focus?.();
}
function close() {
if (options.history?.state?.liveDataStatus) {
options.history.back();
return;
}
finishClose();
}
function open() {
if (!options.sheet.hidden) return;
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
containBackground();
options.sheet.hidden = false;
options.trigger.setAttribute('aria-expanded', 'true');
if (options.history && !options.history.state?.liveDataStatus) {
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
}
options.closeButton.focus();
}
function start() {
options.trigger.addEventListener('click', open);
options.closeButton.addEventListener('click', close);
options.returnButton?.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget?.addEventListener('keydown', event => {
if (options.sheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault?.();
close();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
});
}
return { start, open, close };
}
function mount(options) {
const document = options.document;
const qs = selector => document.querySelector(selector);
const trigger = qs('#open-live-data-status');
trigger.addEventListener('click', options.onOpen);
return createSheetController({
sheet:qs('#live-data-status-sheet'), trigger,
closeButton:qs('#close-live-data-status'),
returnButton:qs('#return-from-live-data-status'),
pausedStatus:qs('#live-data-status-today-paused'),
timerView:options.timerView,
history:options.window.history,
historyTarget:options.window,
escapeTarget:document,
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
});
}
return { describe, createRefreshController, createSheetController, mount };
});
+75 -12
View File
@@ -5,13 +5,34 @@
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
['install', options.installButton, options.installStatus, options.install, 'Install'],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
];
let trigger = options.launcher;
let ownsDetour = false;
let backgroundInert = null;
let pendingResume = true;
function focusableControls() {
return [...options.sheet.querySelectorAll(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)].filter(control => !control.hidden);
}
function containBackground() {
if (backgroundInert) return;
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
}
function releaseBackground() {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
}
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
@@ -34,9 +55,10 @@
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
const step = readiness[name];
status.textContent = step.detail;
button.textContent = step.actionLabel || defaultActionLabel;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
@@ -55,17 +77,31 @@
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
}
await render();
containBackground();
options.sheet.hidden = false;
if (options.history && !options.history.state?.deviceSetup) {
options.history.pushState({...options.history.state, deviceSetup:true}, '');
}
options.closeButton.focus();
}
function close(resume = true) {
function finishClose(resume = true) {
options.sheet.hidden = true;
releaseBackground();
if (resume && ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false;
trigger?.focus?.();
}
function close(resume = true) {
if (options.history?.state?.deviceSetup) {
pendingResume = resume;
options.history.back();
return;
}
finishClose(resume);
}
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
@@ -81,7 +117,28 @@
if (event.target === options.sheet) close();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
if (event.key === 'Escape' && !options.sheet.hidden) {
event.preventDefault?.();
close();
}
if (event.key !== 'Tab' || options.sheet.hidden) return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
options.historyTarget?.addEventListener('popstate', event => {
if (!options.sheet.hidden && !event.state?.deviceSetup) {
finishClose(pendingResume);
pendingResume = true;
}
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
@@ -104,12 +161,15 @@ if (typeof module === 'object' && module.exports) {
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
const window = options.window || options.document.defaultView;
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
protectionButton:qs('#device-setup-protection'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
protectionStatus:qs('#device-setup-protection-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
@@ -119,6 +179,11 @@ function mountMobileDeviceSetup(options) {
timerView:options.timerView,
isMobile:options.isMobile || (() => innerWidth <= 600),
escapeTarget:options.document,
history:window?.history,
historyTarget:window,
backgroundElements:[
qs('header'), qs('main'), qs('#mobile-task-dock'),
].filter(Boolean),
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
@@ -126,15 +191,13 @@ function mountMobileDeviceSetup(options) {
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
protection:options.storageProtectionReadiness(),
push:options.notificationReadiness(),
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
protectStorage:options.protectStorage,
enablePush:options.enablePush,
enableDeadline:options.enableDeadline,
});
+103
View File
@@ -0,0 +1,103 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileFirstTask = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
const doc = typeof document !== 'undefined' ? document : null;
const win = typeof window !== 'undefined' ? window : null;
options = Object.assign({
storage: typeof localStorage !== 'undefined' ? localStorage : null,
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
eventTarget: win,
sheet: doc?.querySelector('#mobile-first-task'),
title: doc?.querySelector('#mobile-first-task-title'),
findButton: doc?.querySelector('#mobile-first-task-find'),
createButton: doc?.querySelector('#mobile-first-task-create'),
setupButton: doc?.querySelector('#mobile-first-task-setup'),
closeButton: doc?.querySelector('#close-mobile-first-task'),
status: doc?.querySelector('#mobile-first-task-status'),
isTodayActive: () => false,
onFind: () => doc?.querySelector('#find-work')?.click(),
onCreate: () => doc?.querySelector('#new-issue')?.click(),
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
}, options);
const prefix = 'stackchain.first-task.v1:';
function account() {
return String(options.getLogin?.() || '').trim().toLowerCase();
}
function key() {
const login = account();
return login ? prefix + login : '';
}
function completed() {
const currentKey = key();
if (!currentKey) return false;
try { return options.storage.getItem(currentKey) === 'complete'; }
catch (_error) { return false; }
}
function required() {
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
}
function markComplete() {
const currentKey = key();
if (!currentKey) return;
try { options.storage.setItem(currentKey, 'complete'); }
catch (_error) {}
}
function render() {
const online = options.isOnline();
const awaitingStart = options.hasWork();
options.findButton.disabled = !online;
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
options.findButton.textContent = 'Find & start';
options.createButton.textContent = 'Create & start';
options.status.textContent = awaitingStart ?
'Your task is ready. Start it from Find or create and start another task.' : online ?
'Choose a task to claim and start, or create and start one of your own.' :
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
}
function open() {
if (!required()) return false;
render();
if (!options.sheet.open) options.sheet.showModal();
(options.findButton.disabled ? options.createButton : options.findButton).focus();
return true;
}
function refresh() {
if (account() && options.isTodayActive() && !completed()) {
markComplete();
if (options.sheet.open) options.sheet.close();
return 'completed';
}
if (options.sheet.open) render();
if (required() && options.hasWork()) return 'awaiting-start';
return required() ? 'required' : 'inactive';
}
function handoff(callback, requiresOnline = false) {
if (requiresOnline && !options.isOnline()) return;
if (options.sheet.open) options.sheet.close();
callback();
}
function start() {
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
options.createButton.addEventListener('click', () => handoff(options.onCreate));
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
options.closeButton.addEventListener('click', () => {
if (options.sheet.open) options.sheet.close();
});
options.eventTarget?.addEventListener('online', render);
options.eventTarget?.addEventListener('offline', render);
}
return {required, open, refresh, render, start};
});
+2
View File
@@ -12,6 +12,7 @@
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
if (options.shouldActivate?.()) return 'activate';
return 'find';
}
@@ -22,6 +23,7 @@
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else if (current === 'activate') options.openActivation();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
+95 -8
View File
@@ -2,11 +2,68 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
notification, serviceWorker, fetchJson,
}) {
let configuration = null;
let pendingIntent = null;
let recoveryPromise = null;
function blockedReadiness() {
return {
state:'blocked',
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
actionLabel:'Check again',
};
}
function pendingRecoveryReadiness() {
return notification.permission === 'granted'
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
: blockedReadiness();
}
function notificationReadiness() {
if (!configuration?.available || control?.disabled) {
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
}
if (configuration.subscribed) {
return {state:'complete', detail:'New update notifications are enabled.'};
}
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
if (notification.permission === 'denied') return blockedReadiness();
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
}
function renderDeliveryHealth() {
const health = Object.values(configuration?.delivery_health || {});
const degraded = health.find(item => item?.state === 'degraded');
if (testControl) testControl.hidden = !configuration?.subscribed;
if (configuration?.subscribed && degraded) {
const count = Number(degraded.consecutive_failures || 1);
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
return;
}
status.textContent = configuration?.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
}
async function testDelivery() {
if (!testControl) return;
testControl.disabled = true;
status.textContent = 'Sending a test notification…';
try {
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
status.textContent = 'Test delivered. Update notifications are working on this device.';
} catch (error) {
status.textContent = 'Test delivery failed. Check your connection, then try again.';
} finally {
testControl.disabled = false;
}
}
function formattedHour(value) {
return `${String(Number(value)).padStart(2, '0')}:00`;
@@ -48,6 +105,7 @@
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
}
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
return configuration.deadline_enabled
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
@@ -67,16 +125,22 @@
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.();
control.checked = false;
if (testControl) testControl.hidden = true;
if (deadlineControl) deadlineControl.checked = false;
configuration.subscribed = false;
configuration.deadline_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.';
}
async function ensureSubscription() {
const permission = await notification.requestPermission();
const permission = notification.permission === 'granted'
? 'granted'
: await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
status.textContent = blockedReadiness().detail;
return null;
}
const registration = await serviceWorker.ready;
@@ -95,11 +159,15 @@
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
configuration.subscribed = true;
if (testControl) testControl.hidden = false;
return subscription;
}
async function enable() {
await ensureSubscription();
pendingIntent = 'updates';
const subscription = await ensureSubscription();
if (subscription) pendingIntent = null;
return subscription;
}
async function change() {
@@ -122,6 +190,7 @@
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (deadlineControl.checked) pendingIntent = 'deadline';
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
@@ -140,6 +209,7 @@
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
pendingIntent = null;
deadlineStatus.textContent = deadlineControl.checked
? enabledDeadlineText(reminderHour, reminderDays)
: 'Deadline reminders are off for this device.';
@@ -160,9 +230,28 @@
return changeDeadline();
}
async function recoverPermission(intent = null) {
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
if (!pendingIntent || notification.permission !== 'granted') return false;
if (recoveryPromise) return recoveryPromise;
recoveryPromise = (async () => {
if (pendingIntent === 'deadline') {
deadlineControl.checked = true;
return changeDeadline();
}
return Boolean(await enable());
})();
try {
return await recoveryPromise;
} finally {
recoveryPromise = null;
}
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
testControl?.addEventListener('click', testDelivery);
deadlineControl?.addEventListener('change', changeDeadline);
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
configuration = await fetchJson('api/v1/push-subscription');
@@ -176,14 +265,12 @@
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
renderDeliveryHealth();
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
renderDeadlineSnooze();
}
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+58 -6
View File
@@ -19,6 +19,31 @@
section, root.document.getElementById(`security-${section}-section`),
]));
let activityCursor = null;
let backgroundInert = null;
const backgroundElements = ['header', 'main', '#mobile-task-dock']
.map(selector => root.document.querySelector?.(selector))
.filter(Boolean);
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
) || [])].filter(control => !control.hidden && !control.disabled);
const containBackground = () => {
if (!backgroundInert) {
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
}
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
};
const releaseBackground = () => {
if (!backgroundInert) return;
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
backgroundInert = null;
};
const finishClose = () => {
devicesSheet.hidden = true;
releaseBackground();
devicesButton?.focus();
onClose();
};
const navigate = (section, { history = true } = {}) => {
if (!sections[section]) section = 'activity';
@@ -43,16 +68,41 @@
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
if (root.history?.state?.stackchainSecuritySection) root.history.back();
});
root.addEventListener?.('popstate', event => {
const section = event.state?.stackchainSecuritySection;
const handleRouteChange = section => {
if (section && !devicesSheet.hidden) {
containBackground();
navigate(section, { history: false });
return;
}
if (!devicesSheet.hidden) {
devicesSheet.hidden = true;
devicesButton?.focus();
onClose();
finishClose();
}
};
root.addEventListener?.('popstate', event => {
handleRouteChange(event.state?.stackchainSecuritySection);
});
root.addEventListener?.('hashchange', () => {
handleRouteChange(root.history?.state?.stackchainSecuritySection);
});
root.document.addEventListener?.('keydown', event => {
if (devicesSheet.hidden) return;
if (event.key === 'Escape') {
event.preventDefault();
if (root.history?.state?.stackchainSecuritySection) root.history.back();
else finishClose();
return;
}
if (event.key !== 'Tab') return;
const controls = focusableControls();
if (!controls.length) return;
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && event.target === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && event.target === last) {
event.preventDefault();
first.focus();
}
});
@@ -81,7 +131,8 @@
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
else if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) {
await Promise.all([renderDevices(), renderSecurityActivity()]);
}
@@ -224,6 +275,7 @@
const open = () => {
if (!devicesSheet) return;
onOpen();
containBackground();
devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus();
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
+3 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v121';
const CACHE = 'stackchain-dashboard-shell-v123';
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;
@@ -15,6 +15,7 @@ const SHELL = [
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/sign-out-review.js',
BASE + 'static/feature-loader.js',
BASE + 'static/workspace-bootstrap.js',
BASE + 'static/conversation-action-hydrator.js',
@@ -112,6 +113,7 @@ const SHELL = [
BASE + 'static/live-data-status.js',
BASE + 'static/mobile-today-command-bar.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js',
+22 -14
View File
@@ -38,9 +38,25 @@
boundary.startActivityHeartbeat();
boundary.startReconnectResume();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const allButton = root.document.getElementById('sign-out-all');
let review;
const features = root.createFeatureLoader({
document: root.document,
urls: {
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
},
});
const openReview = (mode, source) => features.run(
'sign-out', { trigger: source }, () => {
if (!review) review = root.createSignOutReview.mount(boundary);
review.show(mode, source);
},
);
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
source?.addEventListener('click', () => openReview(mode, source))
);
boundary.requestSignOut = source => openReview('current', source || button);
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesStatus = root.document.getElementById('active-devices-status');
@@ -51,14 +67,6 @@
root.stackchainTodayTimerView?.beginDetour?.('security-center');
}
};
const securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
'security-center': root.document.querySelector(
'meta[name="stackchain-feature-security-center"]'
)?.content || '',
},
});
let loadingSecurityCenter = false;
const openSecurityCenter = async () => {
if (loadingSecurityCenter) return;
@@ -67,7 +75,7 @@
devicesSheet.hidden = false;
closeDevices?.focus();
try {
await securityFeatures.run('security-center', {
await features.run('security-center', {
trigger: devicesButton,
status: devicesStatus,
retryLabel: 'Tap Active devices to retry.',
@@ -607,8 +615,8 @@
}
}
async function signOutAllDevices() {
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
async function signOutAllDevices({ reviewed = false } = {}) {
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
if (!confirmed) return false;
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
if (!response.ok) throw new Error('Could not sign out all devices');
+131
View File
@@ -0,0 +1,131 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else {
root.createSignOutReview = factory;
root.createSignOutReview.mount = boundary => {
const document = root.document;
const review = factory({
sheet: document.getElementById('sign-out-review-sheet'),
heading: document.getElementById('sign-out-review-heading'),
summary: document.getElementById('sign-out-review-summary'),
warning: document.getElementById('sign-out-review-warning'),
cancelButton: document.getElementById('cancel-sign-out'),
confirmButton: document.getElementById('confirm-sign-out'),
escapeTarget: document,
historyTarget: root,
history: root.history,
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
getActiveElement: () => document.activeElement,
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
privateDatabases: root.stackchainPrivateDatabases,
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
onConfirm: mode => mode === 'all'
? boundary.signOutAllDevices({ reviewed: true })
: boundary.signOut(),
});
review.start();
return review;
};
}
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
let mode = 'current';
let launcher = null;
let open = false;
let historyEntry = false;
function ownedItemCount(storage) {
if (!storage) return 0;
let count = 0;
for (let index = 0; index < storage.length; index += 1) {
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
}
return count;
}
async function show(nextMode, source) {
mode = nextMode;
launcher = source;
const itemCount = ownedItemCount(options.localStorage)
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
let inventory = { recordCount: 0, unavailable: true };
try {
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
options.summary.textContent = inventory.unavailable
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
if (mode === 'all') {
options.warning.textContent = atRisk
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
} else {
options.warning.textContent = atRisk
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
: 'Signing out clears this device after the session is revoked.';
}
options.confirmButton.textContent = atRisk
? 'Sign out and erase private work'
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
options.sheet.hidden = false;
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
open = true;
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
historyEntry = Boolean(options.history?.back);
options.cancelButton.focus();
}
function close({ restoreFocus = true } = {}) {
if (!open) return;
options.sheet.hidden = true;
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
open = false;
if (restoreFocus) launcher?.focus?.();
}
function dismiss() {
if (historyEntry) {
historyEntry = false;
options.history.back();
} else close();
}
async function confirm() {
options.confirmButton.disabled = true;
try {
await options.onConfirm(mode);
close({ restoreFocus: false });
} catch (_error) {
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
} finally {
options.confirmButton.disabled = false;
}
}
function start() {
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
options.cancelButton?.addEventListener('click', dismiss);
options.confirmButton?.addEventListener('click', confirm);
options.escapeTarget?.addEventListener('keydown', event => {
if (!open) return;
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
if (event.key !== 'Tab') return;
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = options.getActiveElement?.();
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
});
options.historyTarget?.addEventListener('popstate', () => {
historyEntry = false;
close();
});
}
return { start, show, close };
});
+123 -4
View File
@@ -1,6 +1,11 @@
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
function createTodaySummary({
storage = null, getLogin = () => '', share = null, copy = null,
resolveTarget = null, enqueueDurably = null,
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
} = {}) {
let draft = null;
let draftKey = '';
let postPromise = null;
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
@@ -20,6 +25,7 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
...draft,
worked:copyRows(draft.worked),
tomorrow:copyRows(draft.tomorrow),
target:draft.target ? { ...draft.target } : null,
} : null;
};
const persist = () => {
@@ -37,8 +43,15 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
typeof saved.target.state === 'string'));
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
if (saved !== null) storage.removeItem(key);
return null;
}
@@ -75,6 +88,9 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
include_actuals:false,
note:'',
destination:'',
target:null,
operation_id:'',
};
persist();
return snapshot();
@@ -107,6 +123,57 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
persist();
return true;
},
setDestination(value) {
syncAccount();
if (!draft) return null;
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
draft.destination = String(value || '').trim().slice(0, 260);
draft.target = null;
draft.operation_id = '';
persist();
if (!match) return null;
const repository = (match[1] + '/' + match[2]).toLowerCase();
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
},
async validateDestination() {
syncAccount();
const parsed = this.setDestination(draft?.destination || '');
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
draft.target = {
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
};
draft.operation_id = String(createOperationId()).slice(0, 128);
persist();
return { ...draft.target };
},
postSummary() {
if (postPromise) return postPromise;
syncAccount();
const body = this.text();
if (!body) throw new Error('Select at least one summary item or add a note.');
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
const target = { ...draft.target };
const message = {
kind:'search-reply', repository:target.repository, number:target.number,
targetKind:target.kind, body, operationId:draft.operation_id,
};
postPromise = (async () => {
try {
const admission = await enqueueDurably(message);
discard();
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
} finally {
postPromise = null;
}
})();
return postPromise;
},
discard,
text() {
syncAccount();
@@ -162,6 +229,10 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
qs('#today-summary-include-actuals').checked = draft.include_actuals;
qs('#today-summary-note').value = draft.note;
qs('#today-summary-preview').textContent = summary.text();
qs('#today-summary-destination').value = draft.destination || '';
qs('#today-summary-target').textContent = draft.target ?
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
qs('#post-today-summary').disabled = !draft.target;
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
input.addEventListener('change', () => {
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
@@ -202,6 +273,34 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
}
}
async function validateDestination(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Checking destination…';
try {
await summary.validateDestination();
render();
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
} finally {
button.disabled = false;
}
}
async function postDraft(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
try {
const result = await summary.postSummary();
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
result.target.repository + '#' + result.target.number + '.';
close();
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
button.disabled = false;
}
}
function bind() {
qs('#close-today-summary').addEventListener('click', close);
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
@@ -212,21 +311,41 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
summary.setNote(event.currentTarget.value);
qs('#today-summary-preview').textContent = summary.text();
});
qs('#today-summary-destination').addEventListener('input', event => {
summary.setDestination(event.currentTarget.value);
qs('#today-summary-target').textContent = '';
qs('#post-today-summary').disabled = true;
});
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
open, close, render, bind, shareDraft,
open, close, render, bind, shareDraft, validateDestination, postDraft,
resume() { if (summary.restore()) show(); },
};
}
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
const targetResolver = resolveTarget || (async target => {
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
'/preview?kind=issue');
if (String(item.repository || '').toLowerCase() !== target.repository ||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
throw new Error('That exact issue or pull request is not visible to this account.');
}
return { repository:target.repository, number:target.number, kind:item.kind,
title:item.title || '', state:item.state || '' };
});
const summary = createTodaySummary({
storage:localStorage,
getLogin,
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
resolveTarget:targetResolver,
enqueueDurably,
});
const view = createTodaySummaryView({ summary, qs, escapeHtml });
view.bind();
+4 -3
View File
@@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
const validDetour = state => {
const pending = state.detour_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ?
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
};
const validBreak = state => {
@@ -114,7 +114,8 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();
if (state.active_identity === identity && state.entries[identity]?.running) return true;
if (state.active_identity === identity &&
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
settle(state);
state.active_identity = identity;
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
@@ -289,7 +290,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
return write(state) ? { identity:pending.identity, resumed } : null;
},
beginDetour(reason) {
if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null;
const state = read();
const existing = validDetour(state);
if (existing) return existing;
+61 -27
View File
@@ -1,41 +1,75 @@
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
async function loadWorkspace({
document,
window = null,
createLoader = createFeatureLoader,
schedule = callback => setTimeout(callback,750),
}) {
let cameOnline = false;
let replayed = false;
let retryInFlight = null;
const captureOnline = () => { cameOnline = true; };
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
const retryButton = document.querySelector('#retry-workspace');
const url = document.querySelector(
'meta[name="stackchain-feature-today-timer"]'
)?.content || '';
const loader = createLoader({
document,
urls: { 'today-timer': url },
});
if (status) status.textContent = 'Starting workspace…';
try {
await loader.load('today-timer');
if (status) status.textContent = '';
return {
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
if (cameOnline) callback();
},
const urls = {'today-timer':url};
const loader = createLoader({document, urls});
let attempts = 0;
const load = () => {
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
return loader.load('today-timer');
};
const waitForRecovery = () => new Promise(resolve => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) {
retryButton.hidden = retryButton.disabled = false;
}
const recover = () => {
if (retryInFlight) return retryInFlight;
if (retryButton) retryButton.disabled = true;
retryInFlight = load().then(() => {
window?.removeEventListener('online', recover);
resolve();
}).catch(() => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) retryButton.disabled = false;
}).finally(() => {
retryInFlight = null;
});
return retryInFlight;
};
} catch (error) {
window?.removeEventListener('online', captureOnline);
if (window?.location?.reload) {
if (cameOnline) window.location.reload();
else window.addEventListener('online', () => window.location.reload(), { once: true });
window?.addEventListener('online', recover);
retryButton?.addEventListener('click', recover);
});
try {
await load();
} catch {
await new Promise(resolve => schedule(resolve));
try {
await load();
} catch {
await waitForRecovery();
}
if (status) {
status.textContent = window
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
: 'Workspace could not load. Check your connection, then reload to retry.';
}
throw error;
}
window?.removeEventListener('online', captureOnline);
if (retryButton) retryButton.hidden = true;
if (status) status.textContent = '';
return {
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
if (cameOnline) callback();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
+10 -3
View File
@@ -27,14 +27,15 @@ FEATURE_SOURCES = {
),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
"push-notifications": ("static/push-notifications.js",),
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.js"),
"device-setup": (
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
"static/device-storage.js", "static/mobile-device-setup.js",
),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/conversation.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-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.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/search-preview.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/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.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/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-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.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/search-preview.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/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.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",
@@ -114,7 +115,13 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
for name, bundle in feature_bundles.items()
)
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
workspace_preload = (
f'<link rel="preload" as="script" '
f'href="{feature_bundles["today-timer"].runtime_name}">'
)
dashboard_html = dashboard_html.replace(
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
)
dashboard_html = dashboard_html.replace(
"</body>",
f'<script src="{core.runtime_name}"></script>\n</body>',
+51 -4
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Any, Callable
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, private_state_encryption_key
@dataclass(frozen=True)
@@ -29,12 +30,17 @@ class IdempotencyLedger:
max_entries: int,
lock_timeout_seconds: float = 0.1,
clock: Callable[[], float] = time.time,
encryption_key: bytes | None = None,
) -> None:
self.path = Path(path)
self.ttl_seconds = ttl_seconds
self.max_entries = max_entries
self.lock_timeout_seconds = lock_timeout_seconds
self.clock = clock
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="idempotency-ledger",
)
self._initialize()
def _connect(self) -> sqlite3.Connection:
@@ -67,6 +73,17 @@ class IdempotencyLedger:
def _fingerprint(value: tuple[Any, ...]) -> str:
return json.dumps(value, separators=(",", ":"), sort_keys=True)
def _seal(self, value: str, *, key: str, field: str) -> str:
return self._cipher.seal(value, binding=f"{key}:{field}")
def _open(self, payload: str, *, key: str, field: str) -> tuple[str, bool]:
value, legacy = self._cipher.open(payload, binding=f"{key}:{field}")
if legacy:
return json.dumps(value, separators=(",", ":"), sort_keys=True), True
if not isinstance(value, str):
raise RuntimeError("idempotency ledger payload is invalid")
return value, legacy
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
encoded = self._fingerprint(fingerprint)
now = self.clock()
@@ -84,10 +101,40 @@ class IdempotencyLedger:
(key,),
).fetchone()
if row is not None:
if row[0] != encoded:
stored_fingerprint, legacy_fingerprint = self._open(
row[0], key=key, field="fingerprint"
)
if legacy_fingerprint:
connection.execute(
"UPDATE idempotency_operations SET fingerprint = ? "
"WHERE key = ? AND fingerprint = ?",
(
self._seal(
stored_fingerprint, key=key, field="fingerprint"
),
key,
row[0],
),
)
if stored_fingerprint != encoded:
return Reservation("conflict")
if row[1] == "completed":
return Reservation("completed", json.loads(row[2]))
stored_response, legacy_response = self._open(
row[2], key=key, field="response"
)
if legacy_response:
connection.execute(
"UPDATE idempotency_operations SET response_json = ? "
"WHERE key = ? AND response_json = ?",
(
self._seal(
stored_response, key=key, field="response"
),
key,
row[2],
),
)
return Reservation("completed", json.loads(stored_response))
if row[3] <= now - self.ttl_seconds:
return Reservation("uncertain")
return Reservation("pending")
@@ -110,7 +157,7 @@ class IdempotencyLedger:
connection.execute(
"INSERT INTO idempotency_operations "
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
(key, encoded, now),
(key, self._seal(encoded, key=key, field="fingerprint"), now),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
@@ -129,7 +176,7 @@ class IdempotencyLedger:
connection.execute(
"UPDATE idempotency_operations SET status = 'completed', "
"response_json = ?, completed_at = ? WHERE key = ?",
(encoded, self.clock(), key),
(self._seal(encoded, key=key, field="response"), self.clock(), key),
)
except sqlite3.OperationalError as exc:
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
+96 -23
View File
@@ -1,12 +1,12 @@
"""Durable, account-scoped Later deferrals."""
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
class LaterStore:
@@ -17,6 +17,7 @@ class LaterStore:
timeout: float = 1.0,
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
encryption_key: bytes | None = None,
clock=time.time,
):
self.path = Path(path)
@@ -24,6 +25,10 @@ class LaterStore:
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="later",
)
self._initialize()
def _initialize(self) -> None:
@@ -99,11 +104,55 @@ class LaterStore:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "records": {}}
return {"revision": int(row[0]), "records": json.loads(row[1])}
return {"revision": 0, "records": {}}, False
records, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
if not isinstance(records, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "records": records}, legacy
def _sealed_records(self, login: str, records: dict) -> str:
return self._cipher.seal(records, binding=f"plan:{login}")
def _item_revisions(
self, connection: sqlite3.Connection, login: str
) -> tuple[dict[str, int], dict[str, str]]:
revisions: dict[str, int] = {}
stored_ids: dict[str, str] = {}
for stored_item_id, revision in connection.execute(
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
(login,),
):
if stored_item_id.startswith("v1:"):
item_id, _legacy = self._cipher.open(
stored_item_id, binding=f"item-revision:{login}"
)
else:
item_id = stored_item_id
if not isinstance(item_id, str) or not item_id:
raise PrivateStateEncryptionError("private state could not be decrypted")
revisions[item_id] = int(revision)
stored_ids[item_id] = stored_item_id
return revisions, stored_ids
def _migrate_item_ids(
self, connection: sqlite3.Connection, login: str, stored_ids: dict[str, str]
) -> dict[str, str]:
migrated = dict(stored_ids)
for item_id, stored_item_id in stored_ids.items():
if stored_item_id.startswith("v1:"):
continue
sealed_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}"
)
connection.execute(
"UPDATE later_item_revisions SET item_id = ? "
"WHERE login = ? AND item_id = ?",
(sealed_item_id, login, stored_item_id),
)
migrated[item_id] = sealed_item_id
return migrated
@staticmethod
def _validate_wake_at(wake_at: str | None) -> str:
@@ -116,12 +165,21 @@ class LaterStore:
return wake_at
def get(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?",
(self._normalize_login(login),),
(login,),
).fetchone()
return self._snapshot(row)
snapshot, legacy_plan = self._snapshot(row, login)
_revisions, stored_item_ids = self._item_revisions(connection, login)
if row is not None and legacy_plan:
connection.execute(
"UPDATE later_plans SET records = ? WHERE login = ? AND records = ?",
(self._sealed_records(login, snapshot["records"]), login, row[1]),
)
self._migrate_item_ids(connection, login, stored_item_ids)
return snapshot
def apply(
self,
@@ -152,7 +210,7 @@ class LaterStore:
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
snapshot, legacy_plan = self._snapshot(row, login)
records = dict(snapshot["records"])
revision = snapshot["revision"]
accepted: list[str] = []
@@ -161,15 +219,19 @@ class LaterStore:
# Existing databases predate per-item revisions. Conservatively mark
# active deferrals as changed at the latest known plan revision.
item_revisions, stored_item_ids = self._item_revisions(connection, login)
stored_item_ids = self._migrate_item_ids(connection, login, stored_item_ids)
for item_id in records:
connection.execute(
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, item_id, revision),
)
item_revisions = dict(connection.execute(
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
(login,),
).fetchall())
if item_id not in item_revisions:
stored_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}"
)
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, stored_item_id, revision),
)
item_revisions[item_id] = revision
stored_item_ids[item_id] = stored_item_id
batch_start_item_revisions = dict(item_revisions)
for operation in operations:
@@ -218,21 +280,32 @@ class LaterStore:
revision += 1 if changed else 0
if changed:
item_revisions[item_id] = revision
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
(login, item_id, revision),
)
stored_item_id = stored_item_ids.get(item_id)
if stored_item_id is None:
stored_item_id = self._cipher.seal(
item_id, binding=f"item-revision:{login}"
)
stored_item_ids[item_id] = stored_item_id
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
(login, stored_item_id, revision),
)
else:
connection.execute(
"UPDATE later_item_revisions SET revision = ? "
"WHERE login = ? AND item_id = ?",
(revision, login, stored_item_id),
)
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
serialized = self._sealed_records(login, records)
if row is None:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif accepted:
elif accepted or legacy_plan:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
+69 -16
View File
@@ -3,6 +3,7 @@ import base64
import binascii
import hashlib
import hmac
import json
import math
import os
import re
@@ -55,6 +56,7 @@ from src.push_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
dispatch_unread_updates,
send_web_push,
)
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import build_push_subscription_store
@@ -71,6 +73,7 @@ from src.security_event_store import SecurityEventStore, SecurityEventStoreError
from src.suggestion_engine import compute
from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
from src.state_encryption import PrivateStateEncryptionError
from src.views import FRONTEND_BUILD, router as frontend_router
@@ -2158,6 +2161,9 @@ async def push_status(request: Request):
preferences = await asyncio.to_thread(
_push_subscription_store.deadline_preferences, device_id, now=time.time()
)
delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id
)
return {
"available": configuration.enabled,
"subscribed": subscribed,
@@ -2167,6 +2173,7 @@ async def push_status(request: Request):
"reminder_hour": preferences["reminder_hour"],
"reminder_days": preferences["reminder_days"],
"snoozed_until": preferences["snoozed_until"],
"delivery_health": delivery_health,
}
@@ -2220,6 +2227,52 @@ async def unsubscribe_push(request: Request):
return {"subscribed": False}
@app.post("/api/v1/push-subscription/test")
async def test_push_notification(request: Request):
configuration = _push_configuration()
if not configuration.enabled:
raise HTTPException(status_code=503, detail="Push notifications are not configured")
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
subscription = await asyncio.to_thread(
_push_subscription_store.subscription_for_session, device_id
)
if subscription is None:
raise HTTPException(status_code=409, detail="Enable device notifications first")
payload = json.dumps({
"title": "Stackchain notifications are working",
"body": "This device can receive private work alerts.",
"route": "#/device-setup",
"tag": "stackchain-push-test",
}, separators=(",", ":"))
try:
await send_web_push(subscription, payload, configuration)
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(_push_subscription_store.delete_session, device_id)
raise HTTPException(
status_code=409, detail="Re-enable notifications for this device"
) from error
reason = "timeout" if isinstance(error, (asyncio.TimeoutError, TimeoutError)) else "provider"
await asyncio.to_thread(
_push_subscription_store.mark_delivery_failed,
device_id,
"unread",
reason,
)
raise HTTPException(
status_code=503,
detail="Test notification delivery failed",
headers={"Retry-After": "1"},
) from error
await asyncio.to_thread(
_push_subscription_store.mark_delivery_succeeded, device_id, "unread"
)
return {"delivery_state": "healthy", "delivered": True}
@app.put("/api/v1/push-subscription/deadlines")
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
device_id = await dashboard_auth.session_management_id(
@@ -2367,7 +2420,7 @@ async def get_completed_filed_reviews(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_completed_filed_review_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Completed Filed review synchronization is unavailable",
@@ -2388,7 +2441,7 @@ async def merge_completed_filed_reviews(payload: CompletedFiledReviewBatch):
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Completed Filed review synchronization is unavailable",
@@ -2401,7 +2454,7 @@ async def get_saved_searches(response: Response):
login = await _confirmed_login()
try:
snapshot = await asyncio.to_thread(_saved_search_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
@@ -2431,7 +2484,7 @@ async def replace_saved_searches(payload: SavedSearchCollection):
)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Saved Search synchronization is unavailable",
@@ -2487,7 +2540,7 @@ async def get_today_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today synchronization is unavailable",
@@ -2521,7 +2574,7 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
except TodayPlanFull:
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today synchronization is unavailable",
@@ -2534,7 +2587,7 @@ async def get_today_session():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_today_store().get_session, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Today session synchronization is unavailable",
headers={"Retry-After": "1"},
@@ -2553,7 +2606,7 @@ async def update_today_session(payload: TodaySessionUpdate):
status_code=409,
detail={"code": "session_changed", "session": error.session},
)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503, detail="Today session synchronization is unavailable",
headers={"Retry-After": "1"},
@@ -2565,7 +2618,7 @@ async def get_today_recaps(response: Response):
login = await _confirmed_login()
try:
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today recap history is unavailable",
@@ -2587,7 +2640,7 @@ async def save_today_recap(payload: TodayRecap, response: Response):
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Today recap could not be saved",
@@ -2639,7 +2692,7 @@ async def save_today_recap_and_log_time(
raise ValueError("time log target must match the saved recap")
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
semaphore = asyncio.Semaphore(3)
@@ -2708,7 +2761,7 @@ async def get_later_plan():
login = await _confirmed_login()
try:
return await asyncio.to_thread(_later_store().get, login)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Later synchronization is unavailable",
@@ -2738,7 +2791,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
raise HTTPException(
status_code=503,
detail="Later synchronization is unavailable",
@@ -3886,7 +3939,7 @@ async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> Non
_live_snapshot_value = updated
try:
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
# The upstream mutation already succeeded; keep process-local filtering.
pass
@@ -4066,7 +4119,7 @@ async def live_snapshot(
events_revision=events_revision,
notifications_revision=notifications_revision,
)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
return JSONResponse(
{"error": "Gitea live snapshot state is temporarily unavailable"},
status_code=503,
@@ -4361,7 +4414,7 @@ async def defer_notification(
status_code=503,
headers={"Retry-After": "1"},
)
except (OSError, sqlite3.Error):
except (OSError, sqlite3.Error, PrivateStateEncryptionError):
return JSONResponse(
{"error": "Later synchronization is unavailable. Please retry."},
status_code=503,
+47 -2
View File
@@ -57,6 +57,12 @@ class _PinnedHTTPSAdapter(requests.adapters.HTTPAdapter):
return host, tls
def _delivery_failure_reason(error: Exception) -> str:
if isinstance(error, (asyncio.TimeoutError, TimeoutError)):
return "timeout"
return "provider"
async def send_web_push(
subscription: dict,
payload: str,
@@ -214,10 +220,20 @@ async def dispatch_unread_updates(
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
delivery.session_id,
"unread",
_delivery_failure_reason(error),
)
can_send_digest = False
# Leave this device's transient failures unseen for a later
# poll instead of paying the endpoint deadline repeatedly.
break
await asyncio.to_thread(
store.mark_delivery_succeeded, delivery.session_id, "unread"
)
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
@@ -262,12 +278,21 @@ async def dispatch_unread_updates(
store.delete_session, delivery.session_id
)
else:
await asyncio.to_thread(
store.mark_delivery_failed,
delivery.session_id,
"unread",
_delivery_failure_reason(error),
)
await asyncio.to_thread(
store.mark_digest_pending,
delivery.session_id,
overflow_revisions,
)
return count
await asyncio.to_thread(
store.mark_delivery_succeeded, delivery.session_id, "unread"
)
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
@@ -279,10 +304,19 @@ async def dispatch_unread_updates(
async def dispatch_device_safely(delivery) -> int:
try:
return await dispatch_device(delivery)
except Exception:
except Exception as error:
# Device-specific validation, persistence, or provider failures
# must not cancel healthy siblings. Leave any uncheckpointed
# revisions unseen so a later poll can retry them.
try:
await asyncio.to_thread(
store.mark_delivery_failed,
delivery.session_id,
"unread",
_delivery_failure_reason(error),
)
except Exception:
pass
return 0
counts = await asyncio.gather(
@@ -461,9 +495,20 @@ async def _dispatch_deadline_reminders_unlocked(
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
if isinstance(error, UnsafePushEndpoint):
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,
"deadline",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "deadline"
)
await asyncio.to_thread(
store.mark_deadline_reminder_delivered, device.session_id, local_day
)
+84
View File
@@ -49,6 +49,9 @@ class DisabledPushSubscriptionStore:
def is_subscribed(self, session_id: str) -> bool:
return False
def subscription_for_session(self, session_id: str) -> dict | None:
return None
def deadline_preferences(
self, session_id: str, *, now: float | None = None
) -> dict:
@@ -102,6 +105,15 @@ class DisabledPushSubscriptionStore:
def mark_delivered(self, *args, **kwargs) -> None:
return None
def delivery_health(self, *args, **kwargs) -> dict:
return {}
def mark_delivery_failed(self, *args, **kwargs) -> None:
return None
def mark_delivery_succeeded(self, *args, **kwargs) -> None:
return None
def build_push_subscription_store(
path: str | Path, *, push_enabled: bool
@@ -183,6 +195,17 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_delivery_health (
session_id TEXT NOT NULL,
channel TEXT NOT NULL,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_attempted_at REAL NOT NULL,
last_succeeded_at REAL,
reason TEXT,
PRIMARY KEY (session_id, channel),
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
@@ -316,6 +339,67 @@ class PushSubscriptionStore:
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).fetchone() is not None
def subscription_for_session(self, session_id: str) -> dict | None:
with self._connect() as connection:
row = connection.execute(
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
(session_id,),
).fetchone()
return self._open_subscription(session_id, row[0]) if row else None
def delivery_health(self, session_id: str) -> dict:
with self._connect() as connection:
rows = connection.execute(
"""SELECT channel, consecutive_failures, last_attempted_at,
last_succeeded_at, reason
FROM push_delivery_health WHERE session_id = ? ORDER BY channel""",
(session_id,),
).fetchall()
return {
row[0]: {
"state": "degraded" if row[1] else "healthy",
"consecutive_failures": row[1],
"last_attempted_at": row[2],
"last_succeeded_at": row[3],
"reason": row[4],
}
for row in rows
}
def mark_delivery_failed(
self, session_id: str, channel: str, reason: str, *, now: float | None = None
) -> None:
attempted_at = time.time() if now is None else now
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_delivery_health(
session_id, channel, consecutive_failures, last_attempted_at, reason
) VALUES (?, ?, 1, ?, ?)
ON CONFLICT(session_id, channel) DO UPDATE SET
consecutive_failures = consecutive_failures + 1,
last_attempted_at = excluded.last_attempted_at,
reason = excluded.reason""",
(session_id, channel, attempted_at, reason),
)
def mark_delivery_succeeded(
self, session_id: str, channel: str, *, now: float | None = None
) -> None:
attempted_at = time.time() if now is None else now
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_delivery_health(
session_id, channel, consecutive_failures, last_attempted_at,
last_succeeded_at, reason
) VALUES (?, ?, 0, ?, ?, NULL)
ON CONFLICT(session_id, channel) DO UPDATE SET
consecutive_failures = 0,
last_attempted_at = excluded.last_attempted_at,
last_succeeded_at = excluded.last_succeeded_at,
reason = NULL""",
(session_id, channel, attempted_at, attempted_at),
)
def set_deadline_preferences(
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
reminder_days: int = 2,
+34 -11
View File
@@ -1,11 +1,11 @@
"""Durable, account-scoped saved Search views."""
import json
import re
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
@@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError):
class SavedSearchStore:
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
def __init__(
self,
path: str | Path,
*,
limit: int = 20,
timeout: float = 1.0,
encryption_key: bytes | None = None,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="saved-searches",
)
self._initialize()
def _initialize(self) -> None:
@@ -50,19 +61,31 @@ class SavedSearchStore:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
return {"revision": 0, "views": []} if row is None else {
"revision": int(row[0]), "views": json.loads(row[1])
}
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "views": []}, False
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
if not isinstance(views, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), "views": views}, legacy
def _sealed_views(self, login: str, views: list[dict]) -> str:
return self._cipher.seal(views, binding=f"views:{login}")
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?",
(self._login(login),),
(login,),
).fetchone()
return self._snapshot(row)
snapshot, legacy = self._snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
(self._sealed_views(login, snapshot["views"]), login, row[1]),
)
return snapshot
def _normalize(self, views: list[dict]) -> list[dict]:
if not isinstance(views, list):
@@ -113,13 +136,13 @@ class SavedSearchStore:
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(views)
serialized = json.dumps(normalized, separators=(",", ":"))
serialized = self._sealed_views(login, normalized)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
).fetchone()
current = self._snapshot(row)
current, _legacy = self._snapshot(row, login)
if current["revision"] != expected_revision:
raise SavedSearchConflict(current)
revision = expected_revision + 1
+137 -23
View File
@@ -7,6 +7,11 @@ from pathlib import Path
from typing import Callable
from src.private_state import connect_private_sqlite
from src.state_encryption import (
PrivateStateCipher,
PrivateStateEncryptionError,
private_state_encryption_key,
)
class SecurityEventStoreError(RuntimeError):
@@ -39,12 +44,22 @@ class SecurityEventStore:
max_events: int = 10_000,
retention_seconds: int = 90 * 24 * 60 * 60,
lock_timeout_seconds: float = 0.1,
encryption_key: bytes | None = None,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_events = max(1, max_events)
self.retention_seconds = max(1, retention_seconds)
self.lock_timeout_seconds = lock_timeout_seconds
try:
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="security-events",
)
except PrivateStateEncryptionError as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
@staticmethod
def _bounded(value: str | None, limit: int) -> str | None:
@@ -60,10 +75,7 @@ class SecurityEventStore:
"""
CREATE TABLE IF NOT EXISTS security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
method TEXT,
device_label TEXT,
target TEXT,
payload TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
@@ -81,6 +93,11 @@ class SecurityEventStore:
connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
)
plaintext_columns = {"kind", "method", "device_label", "target"}
if "payload" not in columns or plaintext_columns.intersection(columns):
self._migrate_plaintext(
connection, has_payload="payload" in columns
)
connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)"
@@ -95,6 +112,56 @@ class SecurityEventStore:
"Security activity is temporarily unavailable"
) from exc
def _migrate_plaintext(
self, connection: sqlite3.Connection, *, has_payload: bool
) -> None:
connection.execute("PRAGMA secure_delete = ON")
payload_column = "payload," if has_payload else "NULL AS payload,"
rows = connection.execute(
f"SELECT id, {payload_column} kind, method, device_label, target, "
"created_at, status, operation_id FROM security_events ORDER BY id"
).fetchall()
connection.execute(
"""
CREATE TABLE security_events_encrypted (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
)
"""
)
for (
event_id,
payload,
kind,
method,
device_label,
target,
created_at,
status,
operation_id,
) in rows:
connection.execute(
"INSERT INTO security_events_encrypted "
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
(
event_id,
payload
or self._seal_event(
event_id, kind, method, device_label, target
),
created_at,
status,
operation_id,
),
)
connection.execute("DROP TABLE security_events")
connection.execute(
"ALTER TABLE security_events_encrypted RENAME TO security_events"
)
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
connection.execute(
"DELETE FROM security_events WHERE created_at < ?",
@@ -106,6 +173,24 @@ class SecurityEventStore:
(self.max_events,),
)
def _seal_event(
self,
event_id: int,
kind: str,
method: str | None,
device_label: str | None,
target: str | None,
) -> str:
return self._cipher.seal(
{
"kind": self._bounded(kind, 48) or "security_event",
"method": self._bounded(method, 32),
"device_label": self._bounded(device_label, 64),
"target": self._bounded(target, 255),
},
binding=f"event:{event_id}",
)
def record(
self,
kind: str,
@@ -117,16 +202,17 @@ class SecurityEventStore:
now = int(self.clock())
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
(now,),
)
event_id = cursor.lastrowid
payload = self._seal_event(
event_id, kind, method, device_label, target
)
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
"VALUES (?, ?, ?, ?, ?, 'completed')",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
),
"UPDATE security_events SET payload = ? WHERE id = ?",
(payload, event_id),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
@@ -146,16 +232,19 @@ class SecurityEventStore:
operation_id = secrets.token_urlsafe(24)
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(created_at, status, operation_id) "
"VALUES (?, 'pending', ?)",
(now, operation_id),
)
event_id = cursor.lastrowid
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
"UPDATE security_events SET payload = ? WHERE id = ?",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
operation_id,
self._seal_event(
event_id, kind, method, device_label, target
),
event_id,
),
)
self._prune(connection, now)
@@ -204,7 +293,7 @@ class SecurityEventStore:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at, status "
"SELECT id, payload, created_at, status "
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
parameters,
).fetchall()
@@ -214,7 +303,32 @@ class SecurityEventStore:
) from exc
has_more = len(rows) > bounded_limit
visible = rows[:bounded_limit]
events = []
try:
for event_id, payload, created_at, status in visible:
value, _legacy = self._cipher.open(
payload, binding=f"event:{event_id}"
)
if not isinstance(value, dict) or not isinstance(value.get("kind"), str):
raise PrivateStateEncryptionError(
"private state could not be decrypted"
)
events.append(
SecurityEvent(
event_id,
value["kind"],
value.get("method"),
value.get("device_label"),
value.get("target"),
created_at,
status,
)
)
except PrivateStateEncryptionError as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
return SecurityEventPage(
events=[SecurityEvent(*row) for row in visible],
events=events,
next_cursor=visible[-1][0] if has_more else None,
)
+257 -75
View File
@@ -7,6 +7,7 @@ from datetime import date
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
class TodayPlanFull(ValueError):
@@ -31,6 +32,7 @@ class TodayStore:
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
recap_limit: int = 100,
encryption_key: bytes | None = None,
clock=time.time,
):
self.path = Path(path)
@@ -40,6 +42,10 @@ class TodayStore:
self.operation_retention_seconds = operation_retention_seconds
self.recap_limit = recap_limit
self.clock = clock
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_key(),
store="today",
)
self._initialize()
def _initialize(self) -> None:
@@ -162,31 +168,63 @@ class TodayStore:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
ids = json.loads(row[1])
estimates = json.loads(row[3] or "{}")
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}, False
if row[1].startswith("v1:"):
payload, legacy = self._cipher.open(row[1], binding=f"plan:{login}")
if not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
ids = payload.get("ids")
estimates = payload.get("estimates")
capacity_minutes = payload.get("capacity_minutes")
plan_date = payload.get("plan_date")
timezone = payload.get("timezone")
else:
legacy = True
ids = json.loads(row[1])
estimates = json.loads(row[3] or "{}")
capacity_minutes = row[2]
plan_date = row[4] if len(row) > 4 else None
timezone = row[5] if len(row) > 5 else None
if not isinstance(ids, list) or not isinstance(estimates, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
snapshot = {
"revision": int(row[0]),
"ids": ids,
"capacity_minutes": row[2],
"capacity_minutes": capacity_minutes,
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
}
if len(row) > 4 and row[4]:
snapshot["plan_date"] = row[4]
snapshot["timezone"] = row[5]
return snapshot
if plan_date:
snapshot["plan_date"] = plan_date
snapshot["timezone"] = timezone
return snapshot, legacy
def _sealed_plan(self, login: str, snapshot: dict) -> str:
return self._cipher.seal({
"ids": snapshot["ids"],
"capacity_minutes": snapshot["capacity_minutes"],
"estimates": snapshot["estimates"],
"plan_date": snapshot.get("plan_date"),
"timezone": snapshot.get("timezone"),
}, binding=f"plan:{login}")
def get(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?",
(self._normalize_login(login),),
(login,),
).fetchone()
return self._snapshot(row)
snapshot, legacy = self._snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE today_plans SET ids = ?, capacity_minutes = NULL, estimates = '{}', "
"plan_date = NULL, timezone = NULL WHERE login = ? AND ids = ?",
(self._sealed_plan(login, snapshot), login, row[1]),
)
return snapshot
@staticmethod
def _empty_session() -> dict:
@@ -195,20 +233,42 @@ class TodayStore:
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
}
def get_session(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
"FROM today_sessions WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return self._empty_session()
return self._empty_session(), False
if row[1].startswith("v1:"):
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
if not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
return {
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
"elapsed_ms": int(row[3]), "running": bool(row[4]),
"break_deadline_at": row[5], "updated_at": row[6],
}
}, True
def _sealed_session(self, login: str, session: dict) -> str:
return self._cipher.seal({
"device_id": session["device_id"], "identity": session["identity"],
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
"break_deadline_at": session["break_deadline_at"],
}, binding=f"session:{login}")
def get_session(self, login: str) -> dict:
login = self._normalize_login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
"FROM today_sessions WHERE login = ?", (login,),
).fetchone()
session, legacy = self._session_snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE today_sessions SET device_id = ?, identity = '', elapsed_ms = 0, "
"running = 0, break_deadline_at = NULL WHERE login = ? AND device_id = ?",
(self._sealed_session(login, session), login, row[1]),
)
return session
def update_session(
self, login: str, *, base_revision: int, device_id: str,
@@ -222,24 +282,25 @@ class TodayStore:
"SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at "
"FROM today_sessions WHERE login = ?", (login,)
).fetchone()
current_revision = int(current[0]) if current else 0
current_session, _legacy = self._session_snapshot(current, login)
current_revision = current_session["revision"]
if base_revision != current_revision:
session = self._empty_session() if current is None else {
"revision": current_revision, "device_id": current[1], "identity": current[2],
"elapsed_ms": int(current[3]), "running": bool(current[4]),
"break_deadline_at": current[5], "updated_at": current[6],
}
raise TodaySessionConflict(session)
raise TodaySessionConflict(current_session)
revision = current_revision + 1
session = {
"revision": revision, "device_id": device_id, "identity": identity,
"elapsed_ms": elapsed_ms, "running": bool(running),
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
}
connection.execute(
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, "
"identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
"break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
(login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
(login, revision, self._sealed_session(login, session), "", 0, 0, None, updated_at),
)
return self.get_session(login)
return session
@staticmethod
def _normalize_recap_items(items: list[dict]) -> list[dict]:
@@ -273,9 +334,24 @@ class TodayStore:
})
return normalized
@staticmethod
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
items = json.loads(serialized)
def _recap_snapshot(
self, login: str, encrypted_session_id: str, created_at: float, serialized: str
) -> tuple[dict, bool]:
if encrypted_session_id.startswith("v1:"):
session_id, legacy_session = self._cipher.open(
encrypted_session_id, binding=f"recap-id:{login}"
)
items, legacy_items = self._cipher.open(
serialized, binding=f"recap-items:{login}:{session_id}"
)
else:
session_id, legacy_session = encrypted_session_id, True
try:
items, legacy_items = json.loads(serialized), True
except json.JSONDecodeError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error
if not isinstance(session_id, str) or not isinstance(items, list):
raise PrivateStateEncryptionError("private state could not be decrypted")
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
actual = sum(item["actual_minutes"] for item in items)
return {
@@ -285,7 +361,20 @@ class TodayStore:
"estimated_minutes": estimated,
"actual_minutes": actual,
"variance_minutes": actual - estimated,
}
}, legacy_session or legacy_items
def _migrate_recap(self, connection, login: str, stored_session: str, snapshot: dict) -> None:
encrypted_session = self._cipher.seal(
snapshot["session_id"], binding=f"recap-id:{login}"
)
encrypted_items = self._cipher.seal(
snapshot["items"], binding=f"recap-items:{login}:{snapshot['session_id']}"
)
connection.execute(
"UPDATE today_recaps SET session_id = ?, items = ? "
"WHERE login = ? AND session_id = ?",
(encrypted_session, encrypted_items, login, stored_session),
)
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
login = self._normalize_login(login)
@@ -293,19 +382,25 @@ class TodayStore:
raise ValueError("session_id is required and bounded")
session_id = session_id.strip()
normalized = self._normalize_recap_items(items)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
(login, session_id),
).fetchone()
if existing is not None:
return self._recap_snapshot(session_id, existing[0], existing[1])
existing_rows = connection.execute(
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ?", (login,)
).fetchall()
for existing in existing_rows:
snapshot, legacy = self._recap_snapshot(login, *existing)
if snapshot["session_id"] == session_id:
if legacy:
self._migrate_recap(connection, login, existing[0], snapshot)
return snapshot
created_at = self.clock()
encrypted_session_id = self._cipher.seal(session_id, binding=f"recap-id:{login}")
serialized = self._cipher.seal(
normalized, binding=f"recap-items:{login}:{session_id}"
)
connection.execute(
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
(login, session_id, created_at, serialized),
(login, encrypted_session_id, created_at, serialized),
)
connection.execute(
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
@@ -313,53 +408,135 @@ class TodayStore:
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
(login, login, self.recap_limit),
)
return self._recap_snapshot(session_id, created_at, serialized)
return self._recap_snapshot(login, encrypted_session_id, created_at, serialized)[0]
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
login = self._normalize_login(login)
with self._connect() as connection:
rows = connection.execute(
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
(self._normalize_login(login), bounded_limit),
(login, bounded_limit),
).fetchall()
return [self._recap_snapshot(*row) for row in rows]
snapshots = []
for row in rows:
snapshot, legacy = self._recap_snapshot(login, *row)
if legacy:
self._migrate_recap(connection, login, row[0], snapshot)
snapshots.append(snapshot)
return snapshots
def _time_log_snapshot(self, login: str, row) -> tuple[dict, bool]:
encrypted_session, encrypted_identity, payload_value, status_value = row
if encrypted_session.startswith("v1:"):
session_id = self._cipher.open(
encrypted_session, binding=f"time-log-session:{login}"
)[0]
identity = self._cipher.open(
encrypted_identity, binding=f"time-log-identity:{login}:{session_id}"
)[0]
payload, legacy = self._cipher.open(
str(payload_value), binding=f"time-log-payload:{login}:{session_id}:{identity}"
)
if not isinstance(session_id, str) or not isinstance(identity, str) or not isinstance(payload, dict):
raise PrivateStateEncryptionError("private state could not be decrypted")
return {
"session_id": session_id, "identity": identity,
"actual_minutes": payload.get("actual_minutes"), "status": payload.get("status"),
"stored_session": encrypted_session, "stored_identity": encrypted_identity,
}, legacy
return {
"session_id": encrypted_session, "identity": encrypted_identity,
"actual_minutes": payload_value, "status": status_value,
"stored_session": encrypted_session, "stored_identity": encrypted_identity,
}, True
def _sealed_time_log(self, login: str, session_id: str, identity: str, actual: int, status: str) -> tuple[str, str, str]:
return (
self._cipher.seal(session_id, binding=f"time-log-session:{login}"),
self._cipher.seal(identity, binding=f"time-log-identity:{login}:{session_id}"),
self._cipher.seal(
{"actual_minutes": actual, "status": status},
binding=f"time-log-payload:{login}:{session_id}:{identity}",
),
)
def _migrate_time_log(self, connection, login: str, snapshot: dict) -> dict:
sealed_session, sealed_identity, sealed_payload = self._sealed_time_log(
login, snapshot["session_id"], snapshot["identity"],
snapshot["actual_minutes"], snapshot["status"],
)
connection.execute(
"UPDATE today_time_logs SET session_id = ?, identity = ?, actual_minutes = ?, status = 'sealed' "
"WHERE login = ? AND session_id = ? AND identity = ?",
(sealed_session, sealed_identity, sealed_payload, login,
snapshot["stored_session"], snapshot["stored_identity"]),
)
return {**snapshot, "stored_session": sealed_session, "stored_identity": sealed_identity}
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
"""Claim one recap item for upstream logging, returning its current state."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT actual_minutes, status FROM today_time_logs "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
).fetchone()
if row is None:
rows = connection.execute(
"SELECT session_id, identity, actual_minutes, status FROM today_time_logs WHERE login = ?",
(login,),
).fetchall()
match = None
for row in rows:
candidate, legacy = self._time_log_snapshot(login, row)
if candidate["session_id"] == session_id and candidate["identity"] == identity:
match = self._migrate_time_log(connection, login, candidate) if legacy else candidate
break
if match is None:
sealed_session, sealed_identity, sealed_payload = self._sealed_time_log(
login, session_id, identity, actual_minutes, "pending"
)
connection.execute(
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
"VALUES (?, ?, ?, ?, 'pending')",
(login, session_id, identity, actual_minutes),
"VALUES (?, ?, ?, ?, 'sealed')",
(login, sealed_session, sealed_identity, sealed_payload),
)
return "claimed"
if row[0] != actual_minutes:
if match["actual_minutes"] != actual_minutes:
raise ValueError("logged recap time cannot be changed")
if row[1] == "failed":
if match["status"] == "failed":
sealed_payload = self._sealed_time_log(
login, session_id, identity, actual_minutes, "pending"
)[2]
connection.execute(
"UPDATE today_time_logs SET status = 'pending' "
"UPDATE today_time_logs SET actual_minutes = ?, status = 'sealed' "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
(sealed_payload, login, match["stored_session"], match["stored_identity"]),
)
return "claimed"
return row[1]
return match["status"]
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute(
"UPDATE today_time_logs SET status = ? "
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
)
rows = connection.execute(
"SELECT session_id, identity, actual_minutes, status FROM today_time_logs WHERE login = ?",
(login,),
).fetchall()
for row in rows:
match, legacy = self._time_log_snapshot(login, row)
if match["session_id"] != session_id or match["identity"] != identity or match["status"] != "pending":
continue
if legacy:
match = self._migrate_time_log(connection, login, match)
sealed_payload = self._sealed_time_log(
login, session_id, identity, match["actual_minutes"],
"logged" if succeeded else "failed",
)[2]
connection.execute(
"UPDATE today_time_logs SET actual_minutes = ?, status = 'sealed' "
"WHERE login = ? AND session_id = ? AND identity = ?",
(sealed_payload, login, match["stored_session"], match["stored_identity"]),
)
return
def apply(
self,
@@ -384,7 +561,7 @@ class TodayStore:
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
snapshot, _legacy = self._snapshot(row, login)
duplicate = connection.execute(
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
@@ -417,19 +594,22 @@ class TodayStore:
changed = True
revision = snapshot["revision"] + (1 if changed else 0)
serialized_ids = json.dumps(ids, separators=(",", ":"))
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
sealed = self._sealed_plan(login, {
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
"timezone": snapshot.get("timezone"),
})
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
snapshot.get("plan_date"), snapshot.get("timezone")),
(login, revision, sealed, None, "{}", None, None),
)
elif changed:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
(revision, serialized_ids, serialized_estimates, login),
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = NULL, "
"estimates = '{}', plan_date = NULL, timezone = NULL WHERE login = ?",
(revision, sealed, login),
)
self._record_operation(connection, login, operation_id)
result = {
@@ -452,7 +632,7 @@ class TodayStore:
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
snapshot, _legacy = self._snapshot(row, login)
ids = list(snapshot["ids"])
capacity_minutes = snapshot["capacity_minutes"]
estimates = dict(snapshot["estimates"])
@@ -581,19 +761,21 @@ class TodayStore:
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":"))
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
sealed = self._sealed_plan(login, {
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
"plan_date": plan_date, "timezone": timezone,
})
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
(login, revision, sealed, None, "{}", None, None),
)
elif accepted:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
"plan_date = ?, timezone = ? WHERE login = ?",
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
(revision, sealed, None, "{}", None, None, login),
)
result = {
"revision": revision,
+173 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import re
import threading
from pathlib import Path
@@ -15,6 +16,61 @@ from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(390, 844)])
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
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.assigned_issue_numbers = []
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
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": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("First task 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")
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
page.locator('[data-mobile-task="work"]').click()
sheet = page.locator("#mobile-first-task")
expect(sheet).to_be_visible()
expect(page.locator("#mobile-first-task-find")).to_be_focused()
for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#mobile-first-task-find").click()
expect(sheet).to_be_hidden()
expect(page.locator("#find-work-sheet")).to_be_visible()
page.locator("#close-find-work").click()
context.set_offline(True)
page.locator('[data-mobile-task="work"]').click()
expect(sheet).to_be_visible()
expect(page.locator("#mobile-first-task-find")).to_be_disabled()
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
expect(page.locator("#mobile-first-task-create")).to_be_focused()
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
tmp_path: Path, width: int, height: int
@@ -29,6 +85,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
browser_errors: list[str] = []
failed_responses: list[str] = []
workspace_requests: list[str] = []
launch_transfer_events: list[str] = []
live_requests: list[str] = []
try:
@@ -38,6 +95,18 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
cdp = context.new_cdp_session(page)
cdp.send("Network.enable")
cdp.send(
"Network.emulateNetworkConditions",
{
"offline": False,
"latency": 100,
"downloadThroughput": 48 * 1024,
"uploadThroughput": 48 * 1024,
"connectionType": "cellular3g",
},
)
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
@@ -53,9 +122,16 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
)
page.on(
"request",
lambda request: workspace_requests.append(request.url)
if "feature-today-timer-" in request.url
else None,
lambda request: (
workspace_requests.append(request.url),
launch_transfer_events.append("workspace-requested"),
)
if "feature-today-timer-" in request.url else None,
)
page.on(
"requestfinished",
lambda request: launch_transfer_events.append("core-finished")
if "/runtime-" in request.url else None,
)
page.on(
"request",
@@ -69,6 +145,9 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
assert launch_transfer_events.index("workspace-requested") < (
launch_transfer_events.index("core-finished")
), launch_transfer_events
expect(page.locator("#my-work-status")).to_contain_text("2")
initial_live_requests = len(live_requests)
@@ -124,16 +203,47 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
expect(page.locator("#device-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
expect(page.locator("body > header")).to_have_attribute("inert", "")
expect(page.locator("main")).to_have_attribute("inert", "")
expect(dock).to_have_attribute("inert", "")
expect(page.locator("#close-device-setup")).to_be_focused()
page.keyboard.press("Shift+Tab")
expect(page.locator("#clear-private-device-data")).to_be_focused()
page.keyboard.press("Tab")
expect(page.locator("#close-device-setup")).to_be_focused()
for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#close-device-setup").click()
page.go_back()
expect(page.locator("#device-setup-sheet")).to_be_hidden()
expect(page.locator("#open-device-setup")).to_be_focused()
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
expect(page.locator("main")).not_to_have_attribute("inert", "")
expect(dock).not_to_have_attribute("inert", "")
page.locator("#active-devices").click()
expect(page.locator("#active-devices-sheet")).to_be_visible()
expect(page.locator("#security-activity-section")).to_be_visible()
expect(page.locator("body > header")).to_have_attribute("inert", "")
expect(page.locator("main")).to_have_attribute("inert", "")
expect(dock).to_have_attribute("inert", "")
expect(page.locator("#close-active-devices")).to_be_focused()
expect(page.locator("#active-devices-status")).to_contain_text("active device")
expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
page.keyboard.press("Shift+Tab")
assert page.evaluate(
"""
() => {
const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
)].filter(control => !control.hidden);
return document.activeElement === controls.at(-1);
}
"""
)
page.keyboard.press("Tab")
expect(page.locator("#close-active-devices")).to_be_focused()
security_nav = page.locator(".security-section-nav")
expect(security_nav).to_be_visible()
for control in security_nav.locator("button").all():
@@ -143,9 +253,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
page.go_back()
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
expect(page.locator("body > header")).to_have_attribute("inert", "")
expect(page.locator("main")).to_have_attribute("inert", "")
expect(dock).to_have_attribute("inert", "")
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#active-devices")).to_be_focused()
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
expect(page.locator("main")).not_to_have_attribute("inert", "")
expect(dock).not_to_have_attribute("inert", "")
assert len(workspace_requests) == 1, workspace_requests
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
@@ -158,6 +274,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
fake_thread.join(timeout=5)
def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_path: Path):
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()
fake_url = f"http://127.0.0.1:{fake.server_port}"
attempts: list[str] = []
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
page = browser.new_page(viewport={"width": 390, "height": 844})
page.goto(origin + "/", wait_until="networkidle")
def interrupt_once(route):
attempts.append(route.request.url)
if len(attempts) == 1:
route.abort("failed")
else:
route.continue_()
page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
page.locator('input[name="device_label"]').fill("Workspace recovery 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")
page.wait_for_timeout(1500)
resources = page.evaluate(
"performance.getEntriesByType('resource').map(entry => entry.name)"
)
state = {
"attempts": attempts,
"resources": resources,
"action": page.locator("#my-work-action-status").text_content(),
"retry_hidden": page.locator("#retry-workspace").is_hidden(),
"work": page.locator("#my-work-status").text_content(),
}
assert len(attempts) == 2, state
assert "2" in (state["work"] or ""), state
expect(page.locator("#retry-workspace")).to_be_hidden()
expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile sign-out 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, release_server
def test_release_artifact_reviews_private_work_before_mobile_sign_out(tmp_path: Path):
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))
thread = threading.Thread(target=fake.serve_forever, daemon=True)
thread.start()
delete_requests: list[str] = []
browser_errors: list[str] = []
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)))
page.on(
"request",
lambda request: delete_requests.append(request.url)
if request.method == "DELETE" and "/api/v1/session" in request.url
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Sign-out review phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate("localStorage.setItem('stackchain.release-test-draft', 'unsynced')")
launcher = page.locator("#sign-out")
page.locator("#app-menu-toggle").click()
launcher.click()
sheet = page.locator("#sign-out-review-sheet")
expect(sheet).to_be_visible()
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
assert page.evaluate("document.querySelector('main').inert") is True
panel = page.locator(".sign-out-review-panel").bounding_box()
assert panel and panel["height"] <= 844
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert delete_requests == []
page.keyboard.press("Escape")
expect(sheet).to_be_hidden()
expect(launcher).to_be_focused()
assert page.evaluate("document.querySelector('main').inert") is False
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
assert delete_requests == []
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
thread.join(timeout=5)
+27 -5
View File
@@ -530,11 +530,20 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
expect(page.locator("#resume-today-break")).to_be_visible()
page.evaluate("""() => {
const outbox = JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') ||
'{"version":2,"items":[]}');
outbox.items.push({
id: 'already-admitted-blocker', operationId: 'already-admitted-blocker',
kind: 'issue-comment', repository: 'acme/mobile', number: 41,
body: 'Blocked after reload waiting for the design owner', ownerLogin: 'timmy',
status: 'queued', queuedAt: Date.now()
});
localStorage.setItem('stackchain.authored-outbox.v1', JSON.stringify(outbox));
localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({
version: 1,
drafts: {
'issue:acme/mobile:41:': {
body: 'Blocked waiting for the design owner',
body: 'Blocked after reload waiting for the design owner',
operation_id: 'already-admitted-blocker',
blocker_pending: true,
blocker_until: '2000-01-02T03:04:00.000Z'
@@ -574,10 +583,23 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
})""")
assert state["today"] == ["issue:acme/mobile:42:"]
assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00")
comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"]
assert len(comments) == 1
assert comments[0]["number"] == 41
assert comments[0]["body"] == "Blocked waiting for the design owner"
queued_comments = [
item for item in state["outbox"]["items"]
if item["kind"] == "issue-comment"
and item["number"] == 41
and item["body"] == "Blocked after reload waiting for the design owner"
]
delivered_comments = [
{"number": number, "body": body}
for number, body in fake.comments
if number == 41 and body == "Blocked after reload waiting for the design owner"
]
assert len(queued_comments) <= 1
assert len(delivered_comments) <= 1
assert queued_comments or delivered_comments
receipt = (delivered_comments or queued_comments)[0]
assert receipt["number"] == 41
assert receipt["body"] == "Blocked after reload waiting for the design owner"
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert browser_errors == []
browser.close()
@@ -58,6 +58,9 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
storage:localStorage,getLogin:()=> 'timmy',
share:payload=>navigator.share(payload),
copy:text=>navigator.clipboard.writeText(text),
createOperationId:()=> 'release-summary-op',
resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
});
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__summaryTest={summary,view};
@@ -80,6 +83,42 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#today-summary-destination").fill("acme/mobile#41")
page.evaluate(
"""
() => {
window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
}
"""
)
expect(page.locator("#today-summary-target")).to_contain_text(
"acme/mobile#41 · issue · Daily status"
)
expect(page.locator("#post-today-summary")).to_be_enabled()
page.evaluate(
"window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
)
expect(sheet).to_be_hidden()
assert page.evaluate("window.__queuedSummary") == {
"kind": "search-reply",
"repository": "acme/mobile",
"number": 41,
"targetKind": "issue",
"body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
"operationId": "release-summary-op",
}
page.evaluate(
"""
window.__summaryTest.view.open(
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}]
)
"""
)
expect(sheet).to_be_visible()
page.evaluate(
"""
() => {
+1
View File
@@ -63,6 +63,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
"tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_sign_out_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v121" in worker
assert "stackchain-dashboard-shell-v123" in worker
+109 -6
View File
@@ -17,6 +17,7 @@ class Target {
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
let purgeCalls=0, purgeError='';
let inventory={recordCount:0, unavailable:false};
let persistence={persisted:false, persistResult:false, persistedCalls:0, persistCalls:0, error:''};
const localStorage={
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
get length(){return this.values.size;},
@@ -27,11 +28,16 @@ const caches={
async keys(){return Array.from(this.values);},
async delete(key){return this.values.delete(key);},
};
const storageManager={
estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024}),
persisted:async()=>{persistence.persistedCalls++;if(persistence.error)throw new Error(persistence.error);return persistence.persisted;},
persist:async()=>{persistence.persistCalls++;persistence.persisted=persistence.persistResult;return persistence.persistResult;},
};
const controller=createDeviceStorage({
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
inspectPrivateDatabases:async()=>inventory,
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
storageManager,
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
});
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
@@ -49,7 +55,104 @@ process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.t
assert result == {
"summary": "2 MB of 10 MB browser storage used.",
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_refresh_checks_storage_protection_without_requesting_permission():
result = run_scenario("""
await controller.refresh();
process.stdout.write(JSON.stringify({
readiness:await controller.persistenceReadiness(),
persistedCalls:persistence.persistedCalls,
persistCalls:persistence.persistCalls,
detail:detail.textContent,
}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "Offline work uses best-effort browser storage and may be removed under storage pressure.",
},
"persistedCalls": 1,
"persistCalls": 0,
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_explicit_request_confirms_storage_is_protected_before_claiming_success():
result = run_scenario("""
await controller.refresh();
persistence.persistResult=true;
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({
readiness,
persistedCalls:persistence.persistedCalls,
persistCalls:persistence.persistCalls,
detail:detail.textContent,
}));
""")
assert result == {
"readiness": {
"state": "complete",
"detail": "Offline work is protected from automatic browser storage cleanup.",
},
"persistedCalls": 2,
"persistCalls": 1,
"detail": "Storage protection: Protected · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_denied_request_remains_retryable_and_does_not_claim_protection():
result = run_scenario("""
await controller.refresh();
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({readiness,persistCalls:persistence.persistCalls}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
},
"persistCalls": 1,
}
def test_denied_request_keeps_setup_and_storage_panel_in_the_same_state_when_recheck_fails():
result = run_scenario("""
await controller.refresh();
storageManager.persist=async()=>{persistence.persistCalls++;persistence.error='check blocked';return false;};
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "The browser did not grant storage protection. Offline work still works but may be removed under storage pressure.",
},
"detail": "Storage protection: Denied · 1 private browser item · 0 private work records · 1 cached app copy",
}
def test_failed_request_is_retryable_and_updates_storage_status_without_data_loss():
result = run_scenario("""
await controller.refresh();
storageManager.persist=async()=>{persistence.persistCalls++;throw new Error('blocked');};
const readiness=await controller.requestPersistence();
process.stdout.write(JSON.stringify({readiness,detail:detail.textContent,persistCalls:persistence.persistCalls}));
""")
assert result == {
"readiness": {
"state": "incomplete",
"detail": "Storage protection request failed. Offline work still uses best-effort storage; retry when ready.",
},
"detail": "Storage protection: Request failed · 1 private browser item · 0 private work records · 1 cached app copy",
"persistCalls": 1,
}
@@ -69,7 +172,7 @@ process.stdout.write(JSON.stringify({
"caches": ["other-app"],
"storage": ["stackchain.draft.1", "other.preference"],
"status": "Cached app copies cleared. Private work was kept.",
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
"detail": "Storage protection: Best effort · 1 private browser item · 0 private work records · 0 cached app copies",
}
@@ -90,7 +193,7 @@ process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.tex
},
"calls": 1,
"status": "All Stackchain private data was cleared from this device.",
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
"detail": "Storage protection: Best effort · 0 private browser items · 0 private work records · 0 cached app copies",
}
@@ -112,7 +215,7 @@ process.stdout.write(JSON.stringify({
"calls": 0,
"label": "Confirm: clear private work",
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
"detail": "Storage protection: Best effort · 0 private browser items · 3 private work records · 1 cached app copy",
}
@@ -132,7 +235,7 @@ process.stdout.write(JSON.stringify({
assert result == {
"calls": 0,
"label": "Confirm: clear private work",
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
"detail": "Storage protection: Best effort · 0 private browser items · private work status unknown · 1 cached app copy",
}
+19 -3
View File
@@ -15,6 +15,7 @@ from src.views import dashboard, feature_bundle, runtime_bundle, service_worker
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
SCRIPT_PRELOAD = re.compile(r'<link rel="preload" as="script" href="([^"]+)">')
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
@@ -35,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
(changed_frontend / "widgets.js").write_text(
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
(changed_frontend / "session.js").write_text(
(changed_frontend / "session.js").read_text() + "\n// changed\n"
)
changed = build_frontend(changed_frontend)
@@ -48,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
"today-timer", "security-center",
}
assert first.dashboard_html.count("<script src=") == 1
@@ -74,6 +75,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
assert b"function createSignOutReview" not in first.runtime_bytes
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
assert b"function createDetailDefer" not in first.runtime_bytes
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
@@ -119,6 +122,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
build = build_frontend(FRONTEND)
workspace = build.feature_bundles["today-timer"]
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
assert len(build.runtime_gzip_bytes) <= 100 * 1024
assert len(workspace.runtime_gzip_bytes) <= 110 * 1024
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
build = build_frontend(FRONTEND)
bundles = {"core.js": build.runtime_bytes}
+125
View File
@@ -2,7 +2,132 @@ import sqlite3
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
from src.idempotency import IdempotencyLedger
from src.state_encryption import PrivateStateEncryptionError
PRIVATE_KEY = b"i" * 32
def test_authored_payloads_are_encrypted_at_rest_and_replay_after_restart(tmp_path):
database = tmp_path / "idempotency.sqlite3"
fingerprint_canary = "private-issue-body-canary"
response_canary = "private-gitea-response-canary"
ledger = IdempotencyLedger(
database,
ttl_seconds=600,
max_entries=256,
encryption_key=PRIVATE_KEY,
)
assert ledger.reserve("create-201", ("issue-create", fingerprint_canary)).state == "reserved"
ledger.complete("create-201", {"title": response_canary})
persisted = database.read_bytes()
assert fingerprint_canary.encode() not in persisted
assert response_canary.encode() not in persisted
with sqlite3.connect(database) as connection:
fingerprint, response = connection.execute(
"SELECT fingerprint, response_json FROM idempotency_operations WHERE key = ?",
("create-201",),
).fetchone()
assert fingerprint.startswith("v1:")
assert response.startswith("v1:")
reopened = IdempotencyLedger(
database,
ttl_seconds=600,
max_entries=256,
encryption_key=PRIVATE_KEY,
)
replay = reopened.reserve("create-201", ("issue-create", fingerprint_canary))
assert replay.state == "completed"
assert replay.response == {"title": response_canary}
def test_legacy_plaintext_completed_row_is_migrated_without_losing_replay(tmp_path):
database = tmp_path / "legacy.sqlite3"
ledger = IdempotencyLedger(
database,
ttl_seconds=600,
max_entries=256,
encryption_key=PRIVATE_KEY,
clock=lambda: 100.0,
)
with sqlite3.connect(database) as connection:
connection.execute(
"INSERT INTO idempotency_operations "
"(key, fingerprint, status, response_json, created_at, completed_at) "
"VALUES (?, ?, 'completed', ?, ?, ?)",
(
"legacy-comment",
'["issue-comment","stackchain/api",7,"legacy secret"]',
'{"id":42,"body":"legacy response"}',
90.0,
95.0,
),
)
replay = ledger.reserve(
"legacy-comment",
("issue-comment", "stackchain/api", 7, "legacy secret"),
)
assert replay.state == "completed"
assert replay.response == {"id": 42, "body": "legacy response"}
with sqlite3.connect(database) as connection:
fingerprint, response = connection.execute(
"SELECT fingerprint, response_json FROM idempotency_operations WHERE key = ?",
("legacy-comment",),
).fetchone()
assert fingerprint.startswith("v1:")
assert response.startswith("v1:")
def test_wrong_key_fails_closed_before_a_completed_result_can_replay(tmp_path):
database = tmp_path / "wrong-key.sqlite3"
ledger = IdempotencyLedger(
database, ttl_seconds=600, max_entries=256, encryption_key=PRIVATE_KEY
)
fingerprint = ("issue-comment", "stackchain/api", 7, "secret")
assert ledger.reserve("comment-7", fingerprint).state == "reserved"
ledger.complete("comment-7", {"id": 42})
wrong_key = IdempotencyLedger(
database, ttl_seconds=600, max_entries=256, encryption_key=b"x" * 32
)
with pytest.raises(
PrivateStateEncryptionError, match="private state could not be decrypted"
):
wrong_key.reserve("comment-7", fingerprint)
def test_completed_ciphertext_cannot_be_substituted_between_operation_keys(tmp_path):
database = tmp_path / "substitution.sqlite3"
ledger = IdempotencyLedger(
database, ttl_seconds=600, max_entries=256, encryption_key=PRIVATE_KEY
)
fingerprint = ("issue-create", "stackchain/api", "same authored request")
for key, number in (("create-a", 41), ("create-b", 42)):
assert ledger.reserve(key, fingerprint).state == "reserved"
ledger.complete(key, {"number": number})
with sqlite3.connect(database) as connection:
responses = dict(
connection.execute(
"SELECT key, response_json FROM idempotency_operations"
).fetchall()
)
connection.execute(
"UPDATE idempotency_operations SET response_json = ? WHERE key = ?",
(responses["create-b"], "create-a"),
)
with pytest.raises(
PrivateStateEncryptionError, match="private state could not be decrypted"
):
ledger.reserve("create-a", fingerprint)
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
+104
View File
@@ -1,3 +1,4 @@
import json
import sqlite3
import httpx
@@ -5,6 +6,10 @@ import pytest
from src import main
from src.later_store import LaterStore
from src.state_encryption import PrivateStateEncryptionError
PRIVATE_KEY = b"l" * 32
@pytest.mark.anyio
@@ -65,6 +70,105 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
) == {"revision": 2, "records": {}}
def test_later_plans_encrypt_private_content_and_authenticate_the_account(tmp_path):
path = tmp_path / "later.sqlite3"
item_id = "issue:private/repository:363:"
wake_at = "2026-08-10T09:17:00.000Z"
store = LaterStore(path, encryption_key=PRIVATE_KEY)
expected = store.apply(
"timmy", "op-private", "defer", item_id,
wake_at=wake_at, handoff="today",
)
with sqlite3.connect(path) as connection:
records = connection.execute(
"SELECT records FROM later_plans WHERE login = 'timmy'"
).fetchone()[0]
stored_item_id = connection.execute(
"SELECT item_id FROM later_item_revisions WHERE login = 'timmy'"
).fetchone()[0]
assert records.startswith("v1:")
assert stored_item_id.startswith("v1:")
assert item_id not in records + stored_item_id
assert wake_at not in records
assert "today" not in records
assert LaterStore(path, encryption_key=PRIVATE_KEY).get("timmy") == expected
with sqlite3.connect(path) as connection:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
("alexander", 1, records),
)
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
store.get("alexander")
with pytest.raises(PrivateStateEncryptionError, match="private state could not be decrypted"):
LaterStore(path, encryption_key=b"x" * 32).get("timmy")
def test_later_read_atomically_migrates_legacy_private_content_without_new_revision(tmp_path):
path = tmp_path / "later.sqlite3"
item_id = "issue:legacy/private:17:"
records = {item_id: {"wake_at": "2026-08-20T08:00:00.000Z", "handoff": "today"}}
store = LaterStore(path, encryption_key=PRIVATE_KEY)
with sqlite3.connect(path) as connection:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
("timmy", 7, json.dumps(records)),
)
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
("timmy", item_id, 7),
)
assert store.get("timmy") == {"revision": 7, "records": records}
with sqlite3.connect(path) as connection:
migrated_plan = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = 'timmy'"
).fetchone()
migrated_revision = connection.execute(
"SELECT revision, item_id FROM later_item_revisions WHERE login = 'timmy'"
).fetchone()
assert migrated_plan[0] == migrated_revision[0] == 7
assert migrated_plan[1].startswith("v1:")
assert migrated_revision[1].startswith("v1:")
assert item_id not in migrated_plan[1] + migrated_revision[1]
def test_duplicate_write_migrates_legacy_later_content_without_advancing_revision(tmp_path):
path = tmp_path / "later.sqlite3"
item_id = "issue:legacy/private:18:"
records = {item_id: "2026-08-21T08:00:00.000Z"}
store = LaterStore(path, encryption_key=PRIVATE_KEY)
with sqlite3.connect(path) as connection:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
("timmy", 4, json.dumps(records)),
)
connection.execute(
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
("timmy", item_id, 4),
)
connection.execute(
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
("timmy", "already-confirmed", 1.0),
)
result = store.apply(
"timmy", "already-confirmed", "restore", item_id, base_revision=4,
)
assert result == {"revision": 4, "records": records}
with sqlite3.connect(path) as connection:
stored = connection.execute(
"SELECT records FROM later_plans WHERE login = 'timmy'"
).fetchone()[0]
stored_item_id = connection.execute(
"SELECT item_id FROM later_item_revisions WHERE login = 'timmy'"
).fetchone()[0]
assert stored.startswith("v1:")
assert stored_item_id.startswith("v1:")
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
+1 -1
View File
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/later-sync.js'" in source
+107
View File
@@ -81,10 +81,112 @@ const controller = status.createRefreshController({{
}
def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back():
script = f"""
const status = require({json.dumps(str(STATUS))});
function element(name) {{
return {{name, hidden:false, inert:false, listeners:{{}}, focused:0,
addEventListener(type, fn) {{ this.listeners[type] = fn; }},
focus() {{ this.focused += 1; }},
}};
}}
const trigger = element('trigger');
trigger.attrs = {{}};
trigger.setAttribute = (name, value) => trigger.attrs[name] = value;
const close = element('close');
const refresh = element('refresh');
const back = element('return');
const sheet = element('sheet');
sheet.hidden = true;
sheet.querySelectorAll = () => [close, refresh, back];
const header = element('header');
const main = element('main');
const listeners = {{}};
const history = {{state:null, pushes:0, backs:0,
pushState(state) {{ this.state=state; this.pushes += 1; }},
back() {{ this.backs += 1; }},
}};
const timerView = {{begins:[], finishes:0,
beginDetour(reason) {{ this.begins.push(reason); return {{identity:'issue:r:42', reason}}; }},
finishDetour() {{ this.finishes += 1; return {{resumed:true}}; }},
}};
const paused = element('paused');
paused.hidden = true;
const controller = status.createSheetController({{
sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused,
timerView, history, historyTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
escapeTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
backgroundElements:[header, main],
}});
controller.start();
controller.open();
const opened = {{hidden:sheet.hidden, expanded:trigger.attrs['aria-expanded'], paused:paused.hidden, inert:[header.inert, main.inert],
begins:timerView.begins, pushes:history.pushes, closeFocused:close.focused}};
let prevented = 0;
listeners.keydown({{key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++}});
const trapped = {{prevented, closeFocused:close.focused}};
controller.close();
const requested = {{backs:history.backs, finishes:timerView.finishes}};
history.state = null;
listeners.popstate({{state:null}});
process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidden:sheet.hidden, paused:paused.hidden,
expanded:trigger.attrs['aria-expanded'], inert:[header.inert, main.inert], finishes:timerView.finishes,
triggerFocused:trigger.focused}}}}));
"""
result = run_node(script)
assert result == {
"opened": {
"hidden": False,
"expanded": "true",
"paused": False,
"inert": [True, True],
"begins": ["live-data-status"],
"pushes": 1,
"closeFocused": 1,
},
"trapped": {"prevented": 1, "closeFocused": 2},
"requested": {"backs": 1, "finishes": 0},
"closed": {
"hidden": True,
"paused": True,
"expanded": "false",
"inert": [False, False],
"finishes": 1,
"triggerFocused": 1,
},
}
def test_live_data_status_without_running_today_hides_return_and_does_not_resume():
script = f"""
const status = require({json.dumps(str(STATUS))});
function element(hidden=false) {{ return {{hidden, inert:false, listeners:{{}}, attrs:{{}},
addEventListener(name, fn) {{ this.listeners[name]=fn; }}, focus() {{}},
setAttribute(name, value) {{ this.attrs[name]=value; }}, querySelectorAll() {{ return []; }} }}; }}
const sheet=element(true), trigger=element(), close=element(), returnButton=element();
let finishes=0;
const controller=status.createSheetController({{
sheet,trigger,closeButton:close,returnButton,
timerView:{{beginDetour:()=>null,finishDetour:()=>finishes++}},
}});
controller.start();
controller.open();
const opened={{returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']}};
controller.close();
process.stdout.write(JSON.stringify({{opened, finishes}}));
"""
assert run_node(script) == {
"opened": {"returnHidden": True, "expanded": "true"},
"finishes": 0,
}
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
html = HTML.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
status_source = STATUS.read_text()
assert 'id="open-live-data-status"' in html
assert 'aria-controls="live-data-status-sheet"' in html
@@ -92,10 +194,15 @@ def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
assert 'aria-labelledby="live-data-status-heading"' in html
assert 'id="live-data-status-feeds"' in html
assert 'id="refresh-live-data"' in html
assert 'id="return-from-live-data-status"' in html
assert 'id="live-data-status-today-paused"' in html
assert '<script src="static/live-data-status.js"></script>' in html
assert ".live-data-status-panel" in css
assert "width:min(560px,100%)" in css
assert "min-height:44px" in css
assert "liveDataStatus.describe" in dashboard
assert "liveDataStatus.createRefreshController" in dashboard
assert "liveDataStatus.mount" in dashboard
assert "createSheetController" in status_source
assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source
assert "contextPoller.getState()" in dashboard
+1 -1
View File
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v121" in worker
assert "stackchain-dashboard-shell-v123" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v121" in worker
assert "stackchain-dashboard-shell-v123" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+127 -9
View File
@@ -10,7 +10,7 @@ def run_scenario(script: str) -> dict:
harness = r"""
const createMobileDeviceSetup = require(__MODULE__);
class FakeTarget {
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; this.inert = false; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
focus() { state.focused = this; }
@@ -24,10 +24,12 @@ const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true;
const installButton = new FakeTarget();
const offlineButton = new FakeTarget();
const protectionButton = new FakeTarget();
const pushButton = new FakeTarget();
const deadlineButton = new FakeTarget();
const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget();
const protectionStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const deadlineStatus = new FakeTarget();
const readyStatus = new FakeTarget();
@@ -37,6 +39,15 @@ const promptSummary = new FakeTarget();
const promptLauncher = new FakeTarget();
const promptDismiss = new FakeTarget();
const returnButton = new FakeTarget();
const backgroundHeader = new FakeTarget();
const backgroundMain = new FakeTarget(); backgroundMain.inert = true;
sheet.querySelectorAll = () => [closeButton, installButton, offlineButton, returnButton];
const historyTarget = new FakeTarget();
const history = {
state:{section:'home'},
pushState(state) { this.state = state; },
back() { this.state = {section:'home'}; historyTarget.dispatch('popstate', {state:this.state}); },
};
let now = 1000;
const promptStorage = {
values:{},
@@ -46,14 +57,17 @@ const promptStorage = {
let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
protection:{state:'incomplete', detail:'Offline work uses best-effort browser storage.'},
push:{state:'unavailable', detail:'Notifications are unavailable.'},
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
};
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
installStatus, offlineStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
launcher, closeButton, sheet, installButton, offlineButton, protectionButton, pushButton, deadlineButton,
installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss,
returnButton, isMobile:() => true,
backgroundElements:[backgroundHeader, backgroundMain],
history, historyTarget,
timerView:{
beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; },
finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; },
@@ -62,6 +76,7 @@ const setup = createMobileDeviceSetup({
getReadiness:() => { state.events.push('readiness'); return readiness; },
install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; },
protectStorage:async () => { state.protectionCalls = (state.protectionCalls || 0) + 1; },
enablePush:async () => { state.pushCalls += 1; },
enableDeadline:async () => { state.deadlineCalls += 1; },
});
@@ -90,11 +105,33 @@ process.stdout.write(JSON.stringify({
"install": "Stackchain is installed.",
"offline": "Offline work is off.",
"push": "Notifications are unavailable.",
"summary": "1 of 2 available steps ready.",
"summary": "1 of 3 available steps ready.",
"calls": [0, 0, 0],
}
def test_storage_protection_runs_only_from_its_explicit_setup_action():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
const callsOnOpen=state.protectionCalls || 0;
readiness.protection={state:'complete', detail:'Offline work is protected from automatic browser storage cleanup.'};
await protectionButton.dispatch('click');
process.stdout.write(JSON.stringify({
callsOnOpen,
callsAfterAction:state.protectionCalls,
detail:protectionStatus.textContent,
hidden:protectionButton.hidden,
}));
""")
assert result == {
"callsOnOpen": 0,
"callsAfterAction": 1,
"detail": "Offline work is protected from automatic browser storage cleanup.",
"hidden": True,
}
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
result = run_scenario("""
process.stdout.write(JSON.stringify({
@@ -106,7 +143,7 @@ process.stdout.write(JSON.stringify({
assert result == {
"hidden": False,
"summary": "1 of 2 steps complete",
"summary": "1 of 3 steps complete",
"calls": [0, 0, 0],
}
@@ -146,6 +183,7 @@ process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil -
def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
readiness.protection = {state:'complete', detail:'Offline work is protected.'};
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
await offlineButton.dispatch('click');
process.stdout.write(JSON.stringify({
@@ -179,6 +217,60 @@ process.stdout.write(JSON.stringify({
assert result == {"hidden": True, "launcherFocused": True}
def test_open_setup_contains_focus_and_restores_background_inert_state():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
state.focused = returnButton;
let preventedForward = false;
await escapeTarget.dispatch('keydown', {key:'Tab', target:returnButton, preventDefault(){preventedForward=true;}});
const forwardFocus = state.focused === closeButton;
state.focused = closeButton;
let preventedReverse = false;
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true, target:closeButton, preventDefault(){preventedReverse=true;}});
const reverseFocus = state.focused === returnButton;
const inertWhileOpen = [backgroundHeader.inert, backgroundMain.inert];
await closeButton.dispatch('click');
process.stdout.write(JSON.stringify({
preventedForward, forwardFocus, preventedReverse, reverseFocus,
inertWhileOpen,
inertAfterClose:[backgroundHeader.inert, backgroundMain.inert],
launcherFocused:state.focused === launcher,
}));
""")
assert result == {
"preventedForward": True,
"forwardFocus": True,
"preventedReverse": True,
"reverseFocus": True,
"inertWhileOpen": [True, True],
"inertAfterClose": [False, True],
"launcherFocused": True,
}
def test_browser_back_closes_setup_and_restores_launcher_focus():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
const marked = history.state.deviceSetup === true;
history.back();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({
marked,
hidden:sheet.hidden,
backgroundReleased:backgroundHeader.inert === false,
launcherFocused:state.focused === launcher,
}));
""")
assert result == {
"marked": True,
"hidden": True,
"backgroundReleased": True,
"launcherFocused": True,
}
def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once():
result = run_scenario("""
state.events = [];
@@ -227,7 +319,27 @@ process.stdout.write(JSON.stringify({
"calls": 1,
"detail": "Deadline reminders enabled for 08:00 local time.",
"hidden": True,
"summary": "3 of 4 available steps ready.",
"summary": "3 of 5 available steps ready.",
}
def test_blocked_notification_step_becomes_a_check_again_action():
result = run_scenario("""
readiness.push = {
state:'blocked',
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
actionLabel:'Check again',
};
await launcher.dispatch('click', {currentTarget:launcher});
await pushButton.dispatch('click');
process.stdout.write(JSON.stringify({label:pushButton.textContent,detail:pushStatus.textContent,calls:state.pushCalls,hidden:pushButton.hidden}));
""")
assert result == {
"label": "Check again",
"detail": "Notifications are blocked. Allow them in browser settings, then check again.",
"calls": 1,
"hidden": False,
}
@@ -248,7 +360,8 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert 'id="device-setup-today-detour"' in html
assert 'id="return-from-device-setup"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "protection", "push", "deadline"))
assert 'id="device-setup-protection-status"' in html
assert 'id="device-storage-summary"' in html
assert 'id="device-storage-detail"' in html
assert 'id="clear-device-caches"' in html
@@ -260,12 +373,17 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "createMobileDeviceSetup.mount({" in dashboard
assert "timerView," in dashboard
assert "isMobile:options.isMobile || (() => innerWidth <= 600)" in MODULE.read_text()
assert "createDeviceStorage.mount(document)" in dashboard
assert "const deviceStorage = createDeviceStorage.mount(document)" in dashboard
assert "storageProtectionReadiness:() => deviceStorage.persistenceReadiness()" in dashboard
assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
assert "promptStorage:localStorage" in dashboard
assert "notificationReadiness:() => pushController?.notificationReadiness()" in dashboard
assert "controller.recoverPermission('updates')" in dashboard
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-v121" in worker
assert "stackchain-dashboard-shell-v123" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+163
View File
@@ -0,0 +1,163 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-first-task.js"
def run_node(body: str) -> dict:
script = f"""
const createFirstTask = require({json.dumps(str(CONTROLLER))});
class Element {{
constructor() {{ this.listeners = {{}}; this.disabled = false; this.hidden = false; this.open = false; this.textContent = ''; this.focused = false; }}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
showModal() {{ this.open = true; }}
close() {{ this.open = false; this.listeners.close?.(); }}
focus() {{ this.focused = true; }}
}}
{body}
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
result = run_node(
"""
const values = new Map();
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
const sheet = new Element();
let login = 'Timmy';
let hasWork = false;
let todayActive = false;
const controller = createFirstTask({
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
});
const before = [controller.required(), controller.open(), sheet.open];
hasWork = true;
const awaitingStart = controller.refresh();
const stillRequired = controller.required();
todayActive = true;
const completed = controller.refresh();
const timmyRequired = controller.required();
login = 'alexander'; hasWork = false; todayActive = false;
const alexanderRequired = controller.required();
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
"""
)
assert result == {
"before": [True, True, True],
"awaitingStart": "awaiting-start",
"stillRequired": True,
"completed": "completed",
"sheetOpen": False,
"timmyRequired": False,
"alexanderRequired": True,
"values": [["stackchain.first-task.v1:timmy", "complete"]],
}
def test_first_task_activation_resumes_with_finish_starting_guidance():
result = run_node(
"""
const sheet = new Element();
const title = new Element();
const findButton = new Element();
const createButton = new Element();
const status = new Element();
const controller = createFirstTask({
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => true,
isTodayActive:() => false, isOnline:() => true, mediaQuery:{matches:true}, sheet, title,
findButton, createButton, setupButton:new Element(), closeButton:new Element(), status,
onFind() {}, onCreate() {}, onSetup() {},
});
controller.open();
process.stdout.write(JSON.stringify({
required:controller.required(), title:title.textContent, status:status.textContent,
find:findButton.textContent, create:createButton.textContent,
}));
"""
)
assert result == {
"required": True,
"title": "Finish starting your first task",
"status": "Your task is ready. Start it from Find or create and start another task.",
"find": "Find & start",
"create": "Create & start",
}
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
result = run_node(
"""
const calls = [];
const sheet = new Element();
const findButton = new Element();
const createButton = new Element();
const setupButton = new Element();
const closeButton = new Element();
const status = new Element();
let online = false;
const controller = createFirstTask({
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => false,
isOnline:() => online, mediaQuery:{matches:true}, sheet, findButton, createButton,
setupButton, closeButton, status,
onFind:() => calls.push('find'), onCreate:() => calls.push('create'), onSetup:() => calls.push('setup'),
});
controller.start();
controller.open();
const offline = {findDisabled:findButton.disabled, createDisabled:createButton.disabled, status:status.textContent, createFocused:createButton.focused};
findButton.click();
createButton.click();
controller.open();
setupButton.click();
controller.open();
online = true;
controller.render();
findButton.click();
process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
"""
)
assert result == {
"offline": {
"findDisabled": True,
"createDisabled": False,
"status": "You are offline. Create a task now and it will stay in Drafts until you reconnect.",
"createFocused": True,
},
"calls": ["create", "setup", "find"],
"sheetOpen": False,
}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
html = await dashboard()
assert '<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">' in html
assert '<h2 id="mobile-first-task-title">Start your first task</h2>' in html
assert 'id="mobile-first-task-find" type="button">Find &amp; start</button>' in html
assert 'id="mobile-first-task-create" type="button">Create &amp; start</button>' in html
assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready</button>' in html
assert '<script src="static/mobile-first-task.js"></script>' in html
assert "const mobileFirstTask = createMobileFirstTask({" in html
assert "isTodayActive: () => workSession.checkpointed()" in html
assert "shouldActivate: () => mobileFirstTask.required()" in html
assert "openActivation: () => mobileFirstTask.open()" in html
assert "mobileFirstTask.refresh()" in html
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
assert '.mobile-first-task-actions button { min-height:48px;' in html
+1 -1
View File
@@ -243,5 +243,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-v121" in worker
assert "stackchain-dashboard-shell-v123" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+1 -1
View File
@@ -358,7 +358,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
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-v121" in service_worker
assert "stackchain-dashboard-shell-v123" in service_worker
@pytest.mark.anyio
+29
View File
@@ -140,6 +140,35 @@ process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
def test_mobile_work_entry_guides_an_empty_first_workspace_before_find():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const calls = [];
let activationRequired = true;
const entry = createEntry({{
isTodayActive: () => false,
isTodayResumable: () => false,
getTodayCount: () => 0,
getEligibleCount: () => 0,
shouldActivate: () => activationRequired,
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
openActivation: () => calls.push('activate'),
findWork: () => calls.push('find'),
}});
const modes = [entry.open()];
activationRequired = false;
modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["activate", "find"],
"calls": ["activate", "find"],
}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
+1 -1
View File
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v121" in source
assert "stackchain-dashboard-shell-v123" 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
+137 -3
View File
@@ -22,6 +22,7 @@ const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callba
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true};
const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }};
const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback};
@@ -31,10 +32,10 @@ const registration = {pushManager:{
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
}};
const feature = createPushNotifications({
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
serviceWorker: {ready:Promise.resolve(registration)},
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
});
@@ -69,6 +70,27 @@ process.stdout.write(JSON.stringify(state));
assert result["text"] == "New update notifications enabled for this device."
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
state.current = existing;
await feature.init();
const degraded = {text:state.text,hidden:testControl.hidden};
state.server = {delivery_state:'healthy',delivered:true};
await state.testDelivery();
process.stdout.write(JSON.stringify({degraded,text:state.text,hidden:testControl.hidden,disabled:testControl.disabled,requests:state.requests}));
""")
assert result["degraded"] == {
"text": "Update notifications need attention after 3 failed deliveries. Send a test notification.",
"hidden": False,
}
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/test", "POST"]
assert result["text"] == "Test delivered. Update notifications are working on this device."
assert result["hidden"] is False
assert result["disabled"] is False
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
result = run_scenario("""
state.current = existing;
@@ -158,11 +180,121 @@ await feature.enableDeadline();
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
""")
assert denied["checked"] is False
assert denied["readiness"]["state"] == "incomplete"
assert denied["readiness"]["state"] == "blocked"
assert denied["readiness"]["actionLabel"] == "Check again"
assert "blocked" in denied["readiness"]["detail"].lower()
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
def test_blocked_update_setup_can_recheck_without_reprompting_or_mutating():
result = run_scenario("""
state.permission = 'denied';
await feature.init();
control.checked = true;
await state.change();
const blocked = feature.notificationReadiness();
await feature.recoverPermission();
process.stdout.write(JSON.stringify({blocked,after:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
""")
assert result["blocked"] == {
"state": "blocked",
"detail": "Notifications are blocked. Allow them in browser settings, then check again.",
"actionLabel": "Check again",
}
assert result["after"] == result["blocked"]
assert result["prompts"] == 1
assert result["subscriptions"] == 0
assert result["requests"] == [["api/v1/push-subscription", "GET", ""]]
def test_previously_blocked_permission_starts_a_resumable_update_intent():
result = run_scenario("""
state.permission = 'denied';
state.notification.permission = 'denied';
await feature.init();
await feature.recoverPermission('updates');
state.notification.permission = 'granted';
const readyToRecover = feature.notificationReadiness();
await feature.recoverPermission('updates');
process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
""")
assert result["readyToRecover"]["actionLabel"] == "Check again"
assert result["readiness"]["state"] == "complete"
assert result["prompts"] == 0
assert result["subscriptions"] == 1
assert [request[0:2] for request in result["requests"]] == [
["api/v1/push-subscription", "GET"],
["api/v1/push-subscription", "PUT"],
]
def test_permission_recovery_resumes_pending_update_subscription_once():
result = run_scenario("""
state.permission = 'denied';
await feature.init();
control.checked = true;
await state.change();
state.notification.permission = 'granted';
const readyToRecover = feature.notificationReadiness();
await feature.recoverPermission();
await feature.recoverPermission();
process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
""")
assert result["readyToRecover"] == {
"state": "blocked",
"detail": "Notification permission changed. Check again to finish setup.",
"actionLabel": "Check again",
}
assert result["readiness"]["state"] == "complete"
assert result["prompts"] == 1
assert result["subscriptions"] == 1
assert [request[0:2] for request in result["requests"]] == [
["api/v1/push-subscription", "GET"],
["api/v1/push-subscription", "PUT"],
]
def test_permission_recovery_resumes_pending_deadline_choices_once():
result = run_scenario("""
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,reminder_days:2,public_key:'AQID'};
state.permission = 'denied';
await feature.init();
deadlineHour.value = '17';
deadlineDays.value = '7';
await feature.enableDeadline();
const blocked = feature.deadlineReadiness();
state.notification.permission = 'granted';
const readyToRecover = feature.deadlineReadiness();
await feature.recoverPermission();
await feature.recoverPermission();
process.stdout.write(JSON.stringify({blocked,readyToRecover,readiness:feature.deadlineReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
""")
assert result["blocked"]["state"] == "blocked"
assert result["blocked"]["actionLabel"] == "Check again"
assert result["readyToRecover"] == {
"state": "blocked",
"detail": "Notification permission changed. Check again to finish setup.",
"actionLabel": "Check again",
}
assert result["readiness"] == {
"state": "complete",
"detail": "Deadline reminders enabled for 17:00 local time, next 7 days.",
}
assert result["prompts"] == 1
assert result["subscriptions"] == 1
assert [request[0:2] for request in result["requests"]] == [
["api/v1/push-subscription", "GET"],
["api/v1/push-subscription", "PUT"],
["api/v1/push-subscription/deadlines", "PUT"],
]
assert json.loads(result["requests"][-1][2])["reminder_hour"] == 17
assert json.loads(result["requests"][-1][2])["reminder_days"] == 7
def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time():
result = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'};
@@ -220,6 +352,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
assert 'id="push-updates"' in html
assert 'id="push-update-status"' in html
assert 'id="push-test"' in html
assert 'id="push-deadlines"' in html
assert 'id="push-deadline-hour"' in html
assert 'id="push-deadline-days"' in html
@@ -227,6 +360,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
assert 'id="push-deadline-status"' in html
assert '<script src="static/push-notifications.js"></script>' in html
assert "createPushNotifications({" in dashboard
assert "testControl:qs('#push-test')" in dashboard
assert "BASE + 'static/push-notifications.js'" in worker
assert ".push-update-control" in css and "min-height:44px" in css
assert "pywebpush==" in requirements
+151
View File
@@ -200,6 +200,46 @@ def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path
assert second == []
def test_delivery_health_is_bounded_per_channel_and_recovers_after_success(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.mark_delivery_failed("session-a", "unread", "timeout", now=100)
store.mark_delivery_failed("session-a", "unread", "provider", now=110)
store.mark_delivery_failed("session-a", "deadline", "provider", now=120)
assert store.delivery_health("session-a") == {
"unread": {
"state": "degraded",
"consecutive_failures": 2,
"last_attempted_at": 110,
"last_succeeded_at": None,
"reason": "provider",
},
"deadline": {
"state": "degraded",
"consecutive_failures": 1,
"last_attempted_at": 120,
"last_succeeded_at": None,
"reason": "provider",
},
}
store.mark_delivery_succeeded("session-a", "unread", now=130)
assert store.delivery_health("session-a")["unread"] == {
"state": "healthy",
"consecutive_failures": 0,
"last_attempted_at": 130,
"last_succeeded_at": 130,
"reason": None,
}
assert store.delivery_health("session-a")["deadline"]["state"] == "degraded"
def test_subscription_store_reopens_a_delivered_thread_when_its_revision_changes(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
@@ -918,6 +958,42 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
assert store.claim_unseen({8})[0].session_id == "session-a"
@pytest.mark.anyio
async def test_unread_dispatch_records_degraded_health_then_recovers(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
revision = 0
async def unread():
nonlocal revision
revision += 1
return {"items": [{"id": 42, "updated_at": f"r{revision}"}], "complete": True}
async def timeout_send(_subscription, _payload):
raise asyncio.TimeoutError
config = PushConfiguration("public", "private", "mailto:ops@example.com")
for _ in range(3):
assert await dispatch_unread_updates(store, config, unread, timeout_send) == 0
health = store.delivery_health("session-a")["unread"]
assert health["state"] == "degraded"
assert health["consecutive_failures"] == 3
assert health["reason"] == "timeout"
async def succeed(_subscription, _payload):
return None
assert await dispatch_unread_updates(store, config, unread, succeed) == 1
recovered = store.delivery_health("session-a")["unread"]
assert recovered["state"] == "healthy"
assert recovered["consecutive_failures"] == 0
assert recovered["last_succeeded_at"] is not None
@pytest.mark.anyio
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
path = tmp_path / "push.sqlite3"
@@ -967,6 +1043,7 @@ async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_tha
assert [(item.session_id, item.thread_ids) for item in remaining] == [
("failing-device", (8,))
]
assert store.delivery_health("failing-device")["unread"]["reason"] == "timeout"
retried = []
@@ -1184,6 +1261,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"reminder_hour": 9,
"reminder_days": 2,
"snoozed_until": None,
"delivery_health": {},
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert (await main.push_status(request))["subscribed"] is True
@@ -1191,6 +1269,79 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
assert (await main.push_status(request))["subscribed"] is False
@pytest.mark.anyio
async def test_authenticated_device_can_send_a_real_test_notification(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
subscription = {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
}
store.upsert("session-a", subscription)
monkeypatch.setattr(main, "_push_subscription_store", store)
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
monkeypatch.setattr(main, "_push_configuration", lambda: configuration)
async def management_id(_session):
return "session-a"
sent = []
async def send(delivery, payload, config):
sent.append((delivery, json.loads(payload), config))
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main, "send_web_push", send, raising=False)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
assert await main.test_push_notification(request) == {
"delivery_state": "healthy",
"delivered": True,
}
assert sent == [(
subscription,
{
"title": "Stackchain notifications are working",
"body": "This device can receive private work alerts.",
"route": "#/device-setup",
"tag": "stackchain-push-test",
},
configuration,
)]
assert store.delivery_health("session-a")["unread"]["state"] == "healthy"
@pytest.mark.anyio
async def test_test_notification_reports_provider_failure_as_degraded(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
monkeypatch.setattr(
main, "_push_configuration",
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
)
async def management_id(_session):
return "session-a"
async def fail(*_args):
raise asyncio.TimeoutError
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main, "send_web_push", fail)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
with pytest.raises(main.HTTPException) as raised:
await main.test_push_notification(request)
assert raised.value.status_code == 503
assert raised.value.detail == "Test notification delivery failed"
assert store.is_subscribed("session-a") is True
assert store.delivery_health("session-a")["unread"]["reason"] == "timeout"
@pytest.mark.anyio
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+103
View File
@@ -1,8 +1,12 @@
import json
import sqlite3
import httpx
import pytest
from src import main
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
from src.state_encryption import PrivateStateEncryptionError
def view(view_id="release", name="Release queue", query="mobile", **scope):
@@ -16,6 +20,105 @@ def view(view_id="release", name="Release queue", query="mobile", **scope):
}
def test_saved_searches_encrypt_private_views_and_survive_restart(tmp_path):
database = tmp_path / "saved-searches.sqlite3"
key = b"s" * 32
canary = "private-launch-query-canary"
store = SavedSearchStore(database, encryption_key=key)
created = store.replace(
"Timmy",
0,
[view(name="Private launch", query=canary, repository="private/launch")],
)
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT views FROM saved_searches WHERE login = 'timmy'"
).fetchone()[0]
assert payload.startswith("v1:")
assert "Private launch" not in payload
assert canary not in payload
assert "private/launch" not in payload
assert SavedSearchStore(database, encryption_key=key).get("timmy") == created
def test_saved_searches_lazily_migrate_plaintext_without_changing_revision(tmp_path):
database = tmp_path / "saved-searches.sqlite3"
key = b"s" * 32
legacy_views = [view(name="Legacy private view", query="legacy-canary")]
SavedSearchStore(database, encryption_key=key)
with sqlite3.connect(database) as connection:
connection.execute(
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?)",
("timmy", 7, json.dumps(legacy_views)),
)
snapshot = SavedSearchStore(database, encryption_key=key).get("Timmy")
with sqlite3.connect(database) as connection:
migrated = connection.execute(
"SELECT revision, views FROM saved_searches WHERE login = 'timmy'"
).fetchone()
assert snapshot == {"revision": 7, "views": legacy_views}
assert migrated[0] == 7
assert migrated[1].startswith("v1:")
assert "legacy-canary" not in migrated[1]
def test_saved_searches_fail_closed_with_wrong_key(tmp_path):
database = tmp_path / "saved-searches.sqlite3"
SavedSearchStore(database, encryption_key=b"s" * 32).replace(
"timmy", 0, [view(query="wrong-key-canary")]
)
with pytest.raises(
PrivateStateEncryptionError, match="private state could not be decrypted"
):
SavedSearchStore(database, encryption_key=b"x" * 32).get("timmy")
def test_saved_searches_reject_tampered_or_malformed_payloads(tmp_path):
database = tmp_path / "saved-searches.sqlite3"
store = SavedSearchStore(database, encryption_key=b"s" * 32)
store.replace("timmy", 0, [view(query="tamper-canary")])
with sqlite3.connect(database) as connection:
payload = connection.execute(
"SELECT views FROM saved_searches WHERE login = 'timmy'"
).fetchone()[0]
for invalid in (payload[:-1] + ("A" if payload[-1] != "A" else "B"), "not-json"):
with sqlite3.connect(database) as connection:
connection.execute(
"UPDATE saved_searches SET views = ? WHERE login = 'timmy'",
(invalid,),
)
with pytest.raises(
PrivateStateEncryptionError, match="private state could not be decrypted"
):
store.get("timmy")
def test_saved_searches_reject_ciphertext_substituted_between_accounts(tmp_path):
database = tmp_path / "saved-searches.sqlite3"
store = SavedSearchStore(database, encryption_key=b"s" * 32)
store.replace("timmy", 0, [view(query="timmy-private")])
store.replace("alexander", 0, [view(query="alexander-private")])
with sqlite3.connect(database) as connection:
timmy_payload = connection.execute(
"SELECT views FROM saved_searches WHERE login = 'timmy'"
).fetchone()[0]
connection.execute(
"UPDATE saved_searches SET views = ? WHERE login = 'alexander'",
(timmy_payload,),
)
with pytest.raises(
PrivateStateEncryptionError, match="private state could not be decrypted"
):
store.get("alexander")
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
+29
View File
@@ -61,6 +61,35 @@ 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_tampered_security_activity_returns_no_partial_history(security_access):
transport = httpx.ASGITransport(app=main.app, raise_app_exceptions=False)
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",
"device_label": "Phone",
},
)
with sqlite3.connect(security_access / "security.sqlite3") as connection:
payload = connection.execute(
"SELECT payload FROM security_events WHERE id = 1"
).fetchone()[0]
replacement = "A" if payload[-1] != "A" else "B"
connection.execute(
"UPDATE security_events SET payload = ? WHERE id = 1",
(payload[:-1] + replacement,),
)
activity = await client.get("/api/v1/security-events")
assert activity.status_code == 503
assert activity.json() == {
"detail": "Security activity is temporarily unavailable"
}
@pytest.mark.anyio
async def test_security_activity_limit_is_validated(security_access):
transport = httpx.ASGITransport(app=main.app)
+136
View File
@@ -304,3 +304,139 @@ const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecu
)
assert json.loads(completed.stdout) == {"backs": 1}
def test_security_center_contains_focus_and_restores_prior_background_state_on_final_back():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const listeners={{}};
const element=(id='', inert=false)=>({{
id, inert, hidden:false, disabled:false, textContent:'', children:[], attributes:{{}},
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
focus(){{focused=this;}}, replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}},
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}},
scrollIntoView(){{}},
}});
let focused=null;
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices',
'security-section-activity','security-section-devices','security-section-passkeys',
'security-activity-section','security-devices-section','security-passkeys-section'];
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
ids['active-devices-sheet'].hidden=true;
const header=element('header');
const main=element('main', true);
const dock=element('mobile-task-dock');
const dynamicAction=element('revoke-lost-phone');
const hiddenAction=element('hidden-action'); hiddenAction.hidden=true;
const disabledAction=element('disabled-action'); disabledAction.disabled=true;
ids['active-devices-sheet'].querySelectorAll=()=>[
ids['close-active-devices'], dynamicAction, hiddenAction, disabledAction,
];
const documentListeners={{}};
const root={{
document:{{
getElementById:id=>ids[id]||null, createElement:()=>element(),
querySelector:selector=>({{'header':header,'main':main,'#mobile-task-dock':dock}})[selector]||null,
addEventListener:(type,listener)=>documentListeners[type]=listener,
}},
history:{{state:null,pushState(state){{this.state=state;}}}},
location:{{pathname:'/dashboard/',search:''}},
addEventListener:(type,listener)=>listeners[type]=listener,
}};
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
(async()=>{{
const controller=attachSecurityCenter({{root,boundary}});
await controller.open();
const opened={{inert:[header.inert,main.inert,dock.inert],focused:focused?.id}};
let shiftPrevented=false;
documentListeners.keydown({{key:'Tab',shiftKey:true,target:ids['close-active-devices'],preventDefault(){{shiftPrevented=true;}}}});
const afterShift=focused?.id;
let tabPrevented=false;
documentListeners.keydown({{key:'Tab',shiftKey:false,target:dynamicAction,preventDefault(){{tabPrevented=true;}}}});
const afterTab=focused?.id;
controller.navigate('devices');
header.inert=false; // An earlier popstate listener released its own overlay background.
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
header.inert=false; // That listener also reacts to the resulting hashchange.
root.history.state={{stackchainSecuritySection:'activity'}};
listeners.hashchange({{}});
const sectionBack={{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert]}};
root.history.state=null;
listeners.popstate({{state:null}});
console.log(JSON.stringify({{
opened,shiftPrevented,afterShift,tabPrevented,afterTab,sectionBack,
closed:{{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert],focused:focused?.id}},
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], capture_output=True, text=True
)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"opened": {"inert": [True, True, True], "focused": "close-active-devices"},
"shiftPrevented": True,
"afterShift": "revoke-lost-phone",
"tabPrevented": True,
"afterTab": "close-active-devices",
"sectionBack": {"hidden": False, "inert": [True, True, True]},
"closed": {
"hidden": True,
"inert": [False, True, False],
"focused": "active-devices",
},
}
def test_escape_closes_security_center_through_history_and_releases_background():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const windowListeners={{}};
const documentListeners={{}};
const element=(id='')=>({{
id,inert:false,hidden:false,disabled:false,textContent:'',children:[],attributes:{{}},
addEventListener(){{}},focus(){{focused=this.id;}},replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
}});
let focused='';
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices'];
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
ids['active-devices-sheet'].hidden=true;
ids['active-devices-sheet'].querySelectorAll=()=>[ids['close-active-devices']];
const header=element('header');
let backs=0;
const root={{
document:{{
getElementById:id=>ids[id]||null,createElement:()=>element(),
querySelector:selector=>selector==='header'?header:null,
addEventListener:(type,listener)=>documentListeners[type]=listener,
}},
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
location:{{pathname:'/dashboard/',search:''}},
addEventListener:(type,listener)=>windowListeners[type]=listener,
}};
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
(async()=>{{
await attachSecurityCenter({{root,boundary}}).open();
let prevented=false;
documentListeners.keydown({{key:'Escape',preventDefault(){{prevented=true;}}}});
const requested={{backs,prevented,hidden:ids['active-devices-sheet'].hidden,inert:header.inert}};
windowListeners.popstate({{state:null}});
console.log(JSON.stringify({{requested,closed:{{hidden:ids['active-devices-sheet'].hidden,inert:header.inert,focused}}}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], capture_output=True, text=True
)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"requested": {"backs": 1, "prevented": True, "hidden": False, "inert": True},
"closed": {"hidden": True, "inert": False, "focused": "active-devices"},
}
+133 -3
View File
@@ -1,6 +1,137 @@
import sqlite3
from src.security_event_store import SecurityEventStore
import pytest
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
PRIVATE_KEY = b"e" * 32
def test_security_event_schema_exposes_no_plaintext_metadata_columns(tmp_path):
store = SecurityEventStore(
tmp_path / "security.sqlite3", clock=lambda: 1_000, encryption_key=PRIVATE_KEY
)
store.record("issue_closed", method="passkey", target="private/repo#42")
with sqlite3.connect(store.path) as connection:
columns = {
row[1]
for row in connection.execute("PRAGMA table_info(security_events)")
}
assert columns == {
"id",
"payload",
"created_at",
"status",
"operation_id",
}
def test_security_event_payload_is_encrypted_at_rest_and_survives_restart(tmp_path):
path = tmp_path / "security.sqlite3"
canaries = {
"kind": "private_issue_closed_canary",
"method": "private_passkey_canary",
"device_label": "Private Operator Phone Canary",
"target": "private-org/roadmap#4242",
}
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
store.record(**canaries)
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT payload FROM security_events"
).fetchone()[0]
assert payload.startswith("v1:")
database_bytes = path.read_bytes()
assert all(value.encode() not in database_bytes for value in canaries.values())
reopened = SecurityEventStore(path, clock=lambda: 1_001, encryption_key=PRIVATE_KEY)
event = reopened.list(limit=10).events[0]
assert (event.kind, event.method, event.device_label, event.target) == tuple(
canaries.values()
)
def test_legacy_security_events_migrate_without_changing_journal_semantics(tmp_path):
path = tmp_path / "security.sqlite3"
with sqlite3.connect(path) as connection:
connection.executescript(
"""
CREATE TABLE security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
method TEXT,
device_label TEXT,
target TEXT,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
);
INSERT INTO security_events
(id, kind, method, device_label, target, created_at, status, operation_id)
VALUES
(7, 'legacy_sign_in_canary', 'legacy_token_canary',
'Legacy Phone Canary', 'dashboard', 900, 'completed', NULL),
(9, 'legacy_issue_closed_canary', 'passkey',
'Legacy Tablet Canary', 'private/repo#9', 901, 'pending', 'operation-9');
"""
)
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
page = store.list(limit=1)
older = store.list(limit=10, cursor=page.next_cursor)
assert [(event.id, event.kind, event.created_at, event.status) for event in page.events] == [
(9, "legacy_issue_closed_canary", 901, "pending")
]
assert [(event.id, event.kind, event.created_at, event.status) for event in older.events] == [
(7, "legacy_sign_in_canary", 900, "completed")
]
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"
).fetchall()
assert [row[0] for row in rows] == [7, 9]
assert all(row[1].startswith("v1:") for row in rows)
database_bytes = path.read_bytes()
assert b"legacy_sign_in_canary" not in database_bytes
assert b"Legacy Tablet Canary" not in database_bytes
def test_wrong_key_or_tampered_security_activity_fails_closed(tmp_path):
path = tmp_path / "security.sqlite3"
store = SecurityEventStore(path, clock=lambda: 1_000, encryption_key=PRIVATE_KEY)
store.record("issue_closed", target="private/repo#42")
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
SecurityEventStore(
path, clock=lambda: 1_001, encryption_key=b"x" * 32
).list(limit=10)
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT payload FROM security_events WHERE id = 1"
).fetchone()[0]
replacement = "A" if payload[-1] != "A" else "B"
connection.execute(
"UPDATE security_events SET payload = ? WHERE id = 1",
(payload[:-1] + replacement,),
)
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
store.list(limit=10)
def test_missing_security_activity_encryption_key_fails_with_store_error(
monkeypatch, tmp_path
):
monkeypatch.delenv("STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY", raising=False)
with pytest.raises(SecurityEventStoreError, match="temporarily unavailable"):
SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path):
@@ -31,8 +162,7 @@ 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", "kind", "method", "device_label", "target", "created_at",
"status", "operation_id",
"id", "payload", "created_at", "status", "operation_id",
}
+16 -14
View File
@@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
def test_offline_activation_migration_rolls_the_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v121" in source
assert "stackchain-dashboard-shell-v123" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -183,7 +183,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-v121" in source
assert "stackchain-dashboard-shell-v123" 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
@@ -192,7 +192,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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -200,14 +200,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-v121" in source
assert "stackchain-dashboard-shell-v123" 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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -215,7 +215,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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -223,7 +223,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-v121" in source
assert "stackchain-dashboard-shell-v123" 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
@@ -233,14 +233,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-v121" in source
assert "stackchain-dashboard-shell-v123" 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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -249,21 +249,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-v121" in source
assert "stackchain-dashboard-shell-v123" 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-v121" in source
assert "stackchain-dashboard-shell-v123" 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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1137,7 +1137,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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/sign-out-review.js",
"/dashboard/static/feature-loader.js",
"/dashboard/static/workspace-bootstrap.js",
"/dashboard/static/conversation-action-hydrator.js",
@@ -1256,6 +1257,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/live-data-status.js",
"/dashboard/static/mobile-today-command-bar.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-first-task.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js",
+193
View File
@@ -0,0 +1,193 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "sign-out-review.js"
def run_scenario(scenario: str, *, inventory: str = "{recordCount:3, unavailable:false}") -> dict:
harness = r"""
const createSignOutReview = require(__MODULE__);
class Target {
constructor() { this.listeners={}; this.hidden=false; this.textContent=''; this.disabled=false; this.dataset={}; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event={}) { for (const callback of this.listeners[name] || []) await callback({preventDefault() { state.prevented = true; }, shiftKey:false, currentTarget:this, ...event}); }
focus() { state.focused=this; }
contains(target) { return target === this; }
querySelectorAll() { return [cancelButton, confirmButton]; }
}
const state={signedOut:[], focused:null, historyPushes:0, historyBacks:0};
const launcher=new Target();
const allLauncher=new Target();
const sheet=new Target(); sheet.hidden=true;
const heading=new Target();
const summary=new Target();
const warning=new Target();
const cancelButton=new Target();
const confirmButton=new Target();
const escapeTarget=new Target();
const historyTarget=new Target();
const background=new Target(); background.inert=false;
const storage={
values:new Map([['stackchain.private','secret'],['stackchain.draft','draft'],['gitea.preference','keep']]),
get length() { return this.values.size; },
key(index) { return Array.from(this.values.keys())[index] || null; },
};
const history={
pushState() { state.historyPushes += 1; },
back() { state.historyBacks += 1; historyTarget.dispatch('popstate'); },
};
const review=createSignOutReview({
launcher, allLauncher, sheet, heading, summary, warning, cancelButton, confirmButton,
escapeTarget, historyTarget, history, localStorage:storage, sessionStorage:storage,
backgroundTargets:[background], getActiveElement:() => state.focused,
privateDatabases:['private-work'], inspectPrivateDatabases:async () => (__INVENTORY__),
onConfirm:async mode => { if (state.failConfirm) throw new Error('blocked purge'); state.signedOut.push(mode); },
});
(async () => { review.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__INVENTORY__", inventory).replace("__SCENARIO__", scenario)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_private_work_is_counted_before_sign_out_and_requires_sheet_confirmation():
result = run_scenario("""
await launcher.dispatch('click');
const before=[...state.signedOut];
await confirmButton.dispatch('click');
process.stdout.write(JSON.stringify({
before, after:state.signedOut, hidden:sheet.hidden, heading:heading.textContent,
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
}));
""")
assert result == {
"before": [],
"after": ["current"],
"hidden": True,
"heading": "Review sign out",
"summary": "This device has 2 private browser items and 3 private work records.",
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
"action": "Sign out and erase private work",
}
def test_keyboard_focus_is_contained_inside_open_review():
result = run_scenario("""
await launcher.dispatch('click');
state.focused=confirmButton;
await escapeTarget.dispatch('keydown', {key:'Tab'});
const wrappedForward=state.focused === cancelButton;
state.focused=cancelButton;
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true});
process.stdout.write(JSON.stringify({wrappedForward, wrappedBackward:state.focused === confirmButton, prevented:state.prevented || false}));
""")
assert result == {"wrappedForward": True, "wrappedBackward": True, "prevented": True}
def test_cancel_removes_review_history_and_preserves_session_and_private_work():
result = run_scenario("""
await launcher.dispatch('click');
await cancelButton.dispatch('click');
process.stdout.write(JSON.stringify({
signedOut:state.signedOut, hidden:sheet.hidden, historyPushes:state.historyPushes,
historyBacks:state.historyBacks, launcherFocused:state.focused === launcher,
}));
""")
assert result == {
"signedOut": [], "hidden": True, "historyPushes": 1,
"historyBacks": 1, "launcherFocused": True,
}
def test_open_review_makes_background_inert_until_cancelled():
result = run_scenario("""
await launcher.dispatch('click');
const inertWhileOpen=background.inert;
await cancelButton.dispatch('click');
process.stdout.write(JSON.stringify({inertWhileOpen, inertAfterCancel:background.inert}));
""")
assert result == {"inertWhileOpen": True, "inertAfterCancel": False}
def test_sign_out_all_review_combines_global_session_and_local_work_warning():
result = run_scenario("""
await allLauncher.dispatch('click');
const before=[...state.signedOut];
await confirmButton.dispatch('click');
process.stdout.write(JSON.stringify({
before, after:state.signedOut, heading:heading.textContent,
warning:warning.textContent, action:confirmButton.textContent,
}));
""")
assert result == {
"before": [], "after": ["all"],
"heading": "Review sign out on every device",
"warning": "Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.",
"action": "Sign out and erase private work",
}
def test_failed_inventory_never_claims_the_device_is_empty():
result = run_scenario("""
await launcher.dispatch('click');
process.stdout.write(JSON.stringify({
signedOut:state.signedOut, hidden:sheet.hidden,
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
}));
""", inventory="Promise.reject(new Error('blocked'))")
assert result == {
"signedOut": [], "hidden": False,
"summary": "This device has 2 private browser items; private work status is unknown.",
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
"action": "Sign out and erase private work",
}
def test_failed_purge_keeps_review_open_with_recovery_guidance():
result = run_scenario("""
await launcher.dispatch('click');
state.failConfirm=true;
try { await confirmButton.dispatch('click'); } catch (_error) {}
process.stdout.write(JSON.stringify({
signedOut:state.signedOut, hidden:sheet.hidden, disabled:confirmButton.disabled,
warning:warning.textContent,
}));
""")
assert result == {
"signedOut": [], "hidden": False, "disabled": False,
"warning": "Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.",
}
def test_dashboard_renders_and_wires_phone_safe_sign_out_review():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
session = (root / "frontend" / "session.js").read_text()
review = MODULE.read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
assert 'id="sign-out-review-sheet"' in html
assert 'role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading"' in html
assert 'id="sign-out-review-summary"' in html
assert 'id="sign-out-review-warning"' in html
assert 'id="cancel-sign-out"' in html
assert 'id="confirm-sign-out"' in html
assert html.index('static/private-data-inventory.js') < html.index('static/session.js')
assert html.index('static/sign-out-review.js') < html.index('static/session.js')
assert "root.createSignOutReview" in session
assert "inspectPrivateDatabases: root.inspectStackchainPrivateDatabases" in review
assert "stackchain-feature-sign-out" in session
assert "max-height:100dvh" in css
assert ".sign-out-review-actions button { min-height:44px" in css
assert "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/sign-out-review.js'" in worker
+28
View File
@@ -65,6 +65,34 @@ process.stdout.write(JSON.stringify(timer.snapshot()));
}
def test_reactivating_the_active_item_during_reload_preserves_its_timed_break():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 100000;
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
timer.activate('issue:r:42:');
timer.startBreak(5);
const reopened = timer.activate('issue:r:42:');
process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
"""
assert run_node(script) == {
"reopened": True,
"snapshot": {
"identity": "issue:r:42:",
"elapsed_ms": 0,
"running": False,
"break_deadline_at": 400_000,
},
"pending": {
"identity": "issue:r:42:",
"deadline_at": 400_000,
"expired": False,
},
}
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r"""
const values = new Map();
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v121';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v123';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+106
View File
@@ -4,9 +4,115 @@ import pytest
import httpx
from src import main
from src.state_encryption import PrivateStateEncryptionError
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
def test_task_bearing_today_journey_is_encrypted_at_rest_and_survives_restart(tmp_path):
path = tmp_path / "today.sqlite3"
key = b"t" * 32
store = TodayStore(path, encryption_key=key, clock=lambda: 1234.5)
issue = "issue:private-roadmap/launch:4242:"
device = "private-phone-canary"
recap_session = "private-recap-canary"
plan = store.apply_batch("Timmy", [
{"operation_id": "add", "action": "add", "item_id": issue},
{"operation_id": "configure", "action": "configure", "item_id": "plan",
"capacity_minutes": 180, "estimates": {issue: 75}},
])
session = store.update_session(
"timmy", base_revision=0, device_id=device, identity=issue,
elapsed_ms=90_000, running=True,
)
recap = store.save_recap("timmy", recap_session, [
{"identity": issue, "estimate_minutes": 75, "actual_minutes": 91}
])
assert store.begin_time_log("timmy", recap_session, issue, 91) == "claimed"
store.finish_time_log("timmy", recap_session, issue, succeeded=True)
retained = b"".join(
candidate.read_bytes()
for candidate in (path, path.with_name(path.name + "-wal"))
if candidate.exists()
)
for canary in (issue, device, recap_session):
assert canary.encode() not in retained
assert retained.count(b"v1:") >= 4
reopened = TodayStore(path, encryption_key=key)
assert reopened.get("timmy") == {
key: value for key, value in plan.items()
if key not in {"accepted_operation_ids", "duplicate_operation_ids", "rejected_operations"}
}
assert reopened.get_session("timmy") == session
assert reopened.list_recaps("timmy") == [recap]
assert reopened.begin_time_log("timmy", recap_session, issue, 91) == "logged"
def test_legacy_today_rows_migrate_on_use_without_changing_behavior(tmp_path):
path = tmp_path / "today.sqlite3"
key = b"m" * 32
store = TodayStore(path, encryption_key=key)
issue = "issue:legacy-private/project:7:"
with sqlite3.connect(path) as connection:
connection.execute(
"INSERT INTO today_plans VALUES (?, ?, ?, ?, ?, ?, ?)",
("timmy", 4, f'["{issue}"]', 120, f'{{"{issue}":45}}', "2026-08-19", "UTC"),
)
connection.execute(
"INSERT INTO today_sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
("timmy", 3, "legacy-phone", issue, 5000, 1, None, 99.0),
)
connection.execute(
"INSERT INTO today_recaps VALUES (?, ?, ?, ?)",
("timmy", "legacy-session", 88.0,
f'[{ {"identity": issue, "estimate_minutes": 45, "actual_minutes": 50} }]'.replace("'", '"')),
)
connection.execute(
"INSERT INTO today_time_logs VALUES (?, ?, ?, ?, ?)",
("timmy", "legacy-session", issue, 50, "pending"),
)
assert store.get("timmy")["revision"] == 4
assert store.get_session("timmy")["revision"] == 3
assert store.list_recaps("timmy")[0]["items"][0]["identity"] == issue
assert store.begin_time_log("timmy", "legacy-session", issue, 50) == "pending"
retained = path.read_bytes()
for canary in (issue, "legacy-phone", "legacy-session"):
assert canary.encode() not in retained
@pytest.mark.anyio
async def test_tampered_or_account_substituted_today_state_fails_closed_with_retry(monkeypatch, tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, encryption_key=b"a" * 32)
store.apply("timmy", "seed", "add", "issue:private/repo:9:")
with sqlite3.connect(path) as connection:
payload = connection.execute(
"SELECT ids FROM today_plans WHERE login = 'timmy'"
).fetchone()[0]
connection.execute(
"INSERT INTO today_plans(login, revision, ids, estimates) VALUES ('alexander', 1, ?, '{}')",
(payload,),
)
with pytest.raises(PrivateStateEncryptionError):
store.get("alexander")
async def user():
return {"login": "alexander"}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: store)
with pytest.raises(main.HTTPException) as raised:
await main.get_today_plan()
assert raised.value.status_code == 503
assert raised.value.detail == "Today synchronization is unavailable"
assert raised.value.headers == {"Retry-After": "1"}
def test_active_session_is_durable_and_account_scoped(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, clock=lambda: 1234.5)
+111
View File
@@ -116,6 +116,109 @@ process.stdout.write(JSON.stringify({{
assert output["keptAfterCopy"] is False
def test_summary_validates_an_exact_gitea_thread_before_durable_posting():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
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),
}};
const resolved=[];
const admitted=[];
const summary=createSummary({{
storage,getLogin:()=> 'timmy',createOperationId:()=> 'summary-op-1',
resolveTarget:async target=>{{resolved.push(target);return{{...target,kind:'issue',title:'Daily status',state:'open'}};}},
enqueueDurably:async message=>{{admitted.push(message);return{{item:message,durability:'background'}};}},
}});
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
const parsed=summary.setDestination(' StackChain/Status #42 ');
const target=await summary.validateDestination();
const result=await summary.postSummary();
process.stdout.write(JSON.stringify({{
parsed,target,result,resolved,admitted,snapshot:summary.snapshot(),
kept:values.has('stackchain.today-summary-draft.v1.timmy'),
}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert output["parsed"] == {
"repository": "stackchain/status", "number": 42, "label": "stackchain/status#42"
}
assert output["resolved"] == [{"repository": "stackchain/status", "number": 42}]
assert output["target"] == {
"repository": "stackchain/status", "number": 42, "kind": "issue",
"title": "Daily status", "state": "open",
}
assert output["admitted"] == [{
"kind": "search-reply", "repository": "stackchain/status", "number": 42,
"targetKind": "issue", "body": "Today\n- Ship mobile capture",
"operationId": "summary-op-1",
}]
assert output["result"]["status"] == "queued"
assert output["result"]["durability"] == "background"
assert output["snapshot"] is None
assert output["kept"] is False
def test_summary_keeps_reviewed_destination_and_operation_when_admission_fails():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
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),
}};
const options={{
storage,getLogin:()=> 'timmy',createOperationId:()=> 'stable-op',
resolveTarget:async target=>({{...target,kind:'pull',title:'Release candidate',state:'open'}}),
enqueueDurably:async()=>{{throw new Error('storage unavailable');}},
}};
const summary=createSummary(options);
summary.begin([{{identity:'pull:acme/mobile:9:',title:'Review release',actual_minutes:18}}], []);
summary.setDestination('acme/mobile#9');
await summary.validateDestination();
let error='';
try {{ await summary.postSummary(); }} catch (caught) {{ error=caught.message; }}
const restored=createSummary(options);
process.stdout.write(JSON.stringify({{
error,restored:restored.restore(),snapshot:restored.snapshot(),text:restored.text(),
}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert output["error"] == "storage unavailable"
assert output["restored"] is True
assert output["snapshot"]["operation_id"] == "stable-op"
assert output["snapshot"]["target"]["kind"] == "pull"
assert output["text"] == "Today\n- Review release"
def test_summary_coalesces_concurrent_post_taps_into_one_durable_admission():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
let release;
const gate=new Promise(resolve=>release=resolve);
const admitted=[];
const summary=createSummary({{
getLogin:()=> 'timmy',createOperationId:()=> 'one-operation',
resolveTarget:async target=>({{...target,kind:'issue',title:'Status',state:'open'}}),
enqueueDurably:async message=>{{admitted.push(message);await gate;return{{item:message,durability:'background'}};}},
}});
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
summary.setDestination('acme/mobile#41');
await summary.validateDestination();
const first=summary.postSummary();
const second=summary.postSummary();
release();
const results=await Promise.all([first,second]);
process.stdout.write(JSON.stringify({{admitted,results}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert len(output["admitted"]) == 1
assert output["results"][0] == output["results"][1]
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
from src import main
@@ -130,13 +233,21 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
assert 'id="today-summary-tomorrow"' in html
assert 'id="today-summary-include-actuals"' in html
assert 'id="share-today-summary"' in html
assert 'id="today-summary-destination"' in html
assert 'id="validate-today-summary-destination"' in html
assert 'id="post-today-summary"' in html
assert 'id="today-summary-target"' in html
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
assert "setupTodaySummary({" in dashboard
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in dashboard
assert "fetchJson:api" in dashboard
assert "/preview?kind=issue" in (ROOT / "frontend" / "today-summary.js").read_text()
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
assert ".today-summary-actions button { min-height:44px;" in css
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
assert "tests/e2e/test_mobile_today_summary_release.py" in (
ROOT / ".gitea" / "workflows" / "ci.yml"
).read_text()
+1 -1
View File
@@ -191,7 +191,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
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-v121" in source
assert "stackchain-dashboard-shell-v123" in source
assert "BASE + 'static/today-sync.js'" in source
+29
View File
@@ -391,6 +391,35 @@ process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, re
}
def test_live_data_status_detour_excludes_troubleshooting_time_and_resumes_once():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 1000;
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
timer.activate('issue:r:42:');
now = 6000;
const interruption = timer.beginDetour('live-data-status');
now = 26000;
const paused = timer.snapshot();
const returned = timer.returnFromDetour('live-data-status');
now = 28000;
const resumed = timer.snapshot();
const repeated = timer.returnFromDetour('live-data-status');
process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, repeated}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "live-data-status"},
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "live-data-status"},
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
"repeated": None,
}
def test_mobile_detour_view_names_work_and_returns_to_today():
script = SOURCE.read_text() + r"""
const values = new Map();
+71 -48
View File
@@ -4,6 +4,8 @@ from pathlib import Path
BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
def run_bootstrap(scenario: str) -> dict:
@@ -35,33 +37,71 @@ console.log(JSON.stringify({requested,status:status.textContent}));
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails():
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
result = run_bootstrap("""
const status={textContent:''};
const document={
querySelector(selector) {
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}
};
const createLoader=()=>({load:async()=>{throw new Error('network failed');}});
let message='';
try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; }
console.log(JSON.stringify({message,status:status.textContent}));
""")
assert result == {
"message": "network failed",
"status": "Workspace could not load. Check your connection, then reload to retry.",
}
def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects():
result = run_bootstrap("""
const status={textContent:''}; const listeners={}; let reloads=0;
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
if (selector === '#retry-workspace') return retry;
return null;
}};
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
const schedule=callback=>{callback();};
await loadWorkspace({document,createLoader,schedule});
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {"attempts": 2, "status": "", "retryHidden": True}
def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
result = run_bootstrap("""
const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
const retry={hidden:true,disabled:false,addEventListener(name,callback){listeners[name]=callback;}};
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
if (selector === '#retry-workspace') return retry;
return null;
}};
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
await new Promise(resolve=>setImmediate(resolve));
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
const first=listeners.click(); const second=listeners.click();
await Promise.all([first,second,loading]);
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {
"attempts": 3,
"reloads": 0,
"offered": {
"hidden": False,
"disabled": False,
"status": "Workspace unavailable. Reconnect or retry.",
},
"status": "",
"retryHidden": True,
}
def test_workspace_retry_control_is_accessible_and_touch_safe():
index = INDEX.read_text()
css = CSS.read_text()
assert '<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>' in index
assert ".retry-work-route { min-height:44px;" in css
def test_workspace_bootstrap_reconnect_retries_in_place_without_reloading():
result = run_bootstrap("""
const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
const retry={hidden:true,disabled:false,addEventListener(){},removeEventListener(){}};
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
if (selector === '#retry-workspace') return retry;
return null;
}};
const window={
@@ -69,39 +109,22 @@ const window={
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{throw new Error('offline');}});
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
await new Promise(resolve=>setImmediate(resolve));
const waiting=Boolean(listeners.online);
if (listeners.online) listeners.online();
console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
await loading;
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
""")
assert result == {
"attempts": 3,
"waiting": True,
"reloads": 1,
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
"reloads": 0,
"status": "",
}
def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure():
result = run_bootstrap("""
const status={textContent:''}; const listeners={}; let reloads=0;
const document={querySelector(selector) {
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
if (selector === '#my-work-action-status') return status;
return null;
}};
const window={
location:{reload(){reloads++;}},
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}});
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)}));
""")
assert result == {"reloads": 1, "waiting": False}
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
result = run_bootstrap("""
const status={textContent:''}; const listeners={};