Compare commits

...
Author SHA1 Message Date
timmy 163bfad30a feat: select mobile updates to mark read (Closes #545)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 06:00:48 +00:00
timmy 23c8e57a8e Merge pull request 'Surface failed token sign-in alerts in Security activity' (#544)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m7s
2026-08-11 05:37:53 +00:00
timmy 350bfc067d feat: surface failed token sign-in alerts (Closes #543)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m10s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 05:35:35 +00:00
timmy 98aecd8255 Merge pull request 'Lazy-load the complete pull request workspace' (#542) from timmy/541-lazy-pull-workflow into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m7s
2026-08-11 04:55:19 +00:00
timmy 7d4ff1f095 perf: lazy-load pull request workspace (Closes #541)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:53:18 +00:00
timmy 3605c42651 Merge pull request 'Acknowledge unread updates with a reaction and open the next' (#540) from timmy/539-acknowledge-update-reaction into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m5s
2026-08-11 04:12:18 +00:00
timmy d7e2813700 feat: acknowledge unread updates with reactions (Closes #539)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 04:10:18 +00:00
timmy fb34b5ffda Merge pull request 'Lazy-load the mobile issue capture workflow' (#538) from timmy/537-lazy-issue-capture into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m8s
2026-08-11 03:32:15 +00:00
timmy 820da7130d feat: lazy-load issue capture workflow (Closes #537)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:30:07 +00:00
timmy 3bad8a0198 Merge pull request 'Keep passkey sign-in available during anonymous challenge floods' (#536) from timmy/535-passkey-flood-protection into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m11s
2026-08-11 03:04:42 +00:00
timmy 8bc5f623ac feat: protect passkey sign-in from challenge floods (Closes #535)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 03:02:42 +00:00
timmy 175817e6d8 Merge pull request 'Manage Today blockers offline and sync on reconnect' (#534) from timmy/533-offline-blocker-sync into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m9s
2026-08-11 02:32:31 +00:00
timmy fff7bccf08 feat: sync Today blockers offline (Closes #533)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:29:56 +00:00
timmy ee36b8824a Merge pull request 'Manage issue blockers without leaving Today' (#532) from timmy/531-manage-issue-blockers into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m15s
2026-08-11 02:04:33 +00:00
timmy 2c8a1225e0 feat: manage issue blockers in app (Closes #531)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 02:02:23 +00:00
timmy 29ceedfc01 Merge pull request 'Keep dashboard live when IndexedDB stalls' (#530) from timmy/529-indexeddb-startup-deadline into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m13s
2026-08-11 01:31:05 +00:00
timmy 11a56b21a1 fix: bound IndexedDB dashboard startup (Closes #529)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:28:56 +00:00
timmy 3e16dd00d1 Merge pull request 'Attach screenshots to unread update replies' (#528) from timmy/527-update-reply-screenshots into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m11s
2026-08-11 01:10:54 +00:00
timmy 31f6097afc feat: attach screenshots to update replies (Closes #527)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 01:04:18 +00:00
timmy beaf5e0587 Merge pull request 'Move offline work details from localStorage to IndexedDB' (#526) from timmy/525-indexeddb-offline-work into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m3s
2026-08-11 00:36:26 +00:00
timmy 0a96dfb173 feat: move offline work into IndexedDB (Closes #525)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:34:25 +00:00
timmy 7cadb36b41 Merge pull request 'Lock cached dashboard after the confirmed idle deadline' (#524) from timmy/523-confirmed-offline-idle-deadline into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m12s
2026-08-11 00:02:39 +00:00
timmy b1890a29ff feat: enforce confirmed offline idle deadlines (Closes #523)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 00:00:19 +00:00
timmy 100d38cc5c Merge pull request 'Refresh pull request checks without reloading review data' (#522) from timmy/521-status-only-check-refresh into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m5s
2026-08-10 23:28:26 +00:00
timmy e5a352b418 perf: isolate pull check refreshes (Closes #521)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 23:26:35 +00:00
timmy 8907263760 Merge pull request 'Avoid decoding unchanged live snapshots on every poll' (#520) from timmy/519-metadata-only-live-polls into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m2s
2026-08-10 22:56:35 +00:00
timmy fdcd0d31ab perf: reuse unchanged live snapshot values (Closes #519)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:54:08 +00:00
timmy 5bef6291e8 Merge pull request 'Show actionable CI checks in mobile pull reviews' (#518) from timmy/517-actionable-ci-checks into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m4s
2026-08-10 22:33:52 +00:00
timmy 9724358c89 feat: expose actionable pull request checks (Closes #517)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 22:31:50 +00:00
timmy 7555cefccb Merge pull request 'Attach screenshots to assigned pull request comments online and offline' (#516) from timmy/515-pr-comment-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m10s
2026-08-10 21:44:58 +00:00
timmy 04621223fd feat: attach screenshots to pull comments (Closes #515)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 21:39:22 +00:00
timmy e5016d8b86 Merge pull request 'Audit passkey enrollment with durable Security activity' (#514) from timmy/513-audit-passkey-enrollment into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 59s
2026-08-10 20:58:07 +00:00
timmy b455dc6286 feat: audit passkey enrollment (Closes #513)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:56:20 +00:00
timmy 338b6ebbd0 Make every PWA shell release content-addressed and atomic
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m5s
Closes #511
2026-08-10 20:27:06 +00:00
timmy f18c59c7b3 feat: content-address complete PWA shell (Closes #511)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 20:25:05 +00:00
timmy e9cab92aff Merge pull request 'Make consequential pull reviews passkey-native and auditable' (#510) from timmy/509-passkey-review-activity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m3s
2026-08-10 19:59:06 +00:00
timmy 877dc475dd feat: make consequential reviews passkey-native (Closes #509)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m0s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:56:12 +00:00
timmy 28e70f6415 Merge pull request 'Require fresh authorization for consequential pull reviews' (#508) from timmy/507-fresh-review-authorization into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m1s
2026-08-10 19:04:01 +00:00
timmy 2a6f2ab12d feat: authorize consequential pull reviews (Closes #507)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 19:02:16 +00:00
timmy c8809a0728 Merge pull request 'Take ownership and start work from an unread update' (#506) from timmy/505-take-ownership-start into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m7s
2026-08-10 18:31:05 +00:00
timmy 2717f41957 feat: start work from unread updates (Closes #505)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m9s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:29:03 +00:00
timmy b26b57d616 Merge pull request 'Reply, mark read, and continue unread updates' (#504) from timmy/503-reply-read-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m8s
2026-08-10 18:04:35 +00:00
timmy 1052ce2c84 feat: reply, mark read, and continue updates (Closes #503)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m8s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 18:02:34 +00:00
timmy 4f0e6544b3 Merge pull request 'Make access revocation atomic across passkeys and sessions' (#502) from timmy/501-atomic-access-revocation into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m4s
2026-08-10 17:27:01 +00:00
timmy 91c59a2953 fix: make access revocation atomic (Closes #501)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:24:54 +00:00
timmy 6d1fa3c068 Merge pull request 'Manage and selectively revoke enrolled passkeys' (#500) from timmy/499-manage-enrolled-passkeys into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m3s
2026-08-10 17:08:35 +00:00
timmy 21a386b94f feat: manage enrolled passkeys (Closes #499)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 17:06:36 +00:00
timmy 7e5b966f77 Merge pull request 'Make destructive actions truthful when security journaling fails' (#498) from timmy/497-truthful-security-journal into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m4s
Merge pull request #498: truthful security-journaled actions
2026-08-10 16:32:39 +00:00
timmy 78d044f1af feat: make security-journaled actions outcome truthful (Closes #497)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 16:30:31 +00:00
timmy 814c5726ec Merge pull request 'Optimize oversized screenshots before mobile capture' (#496) from timmy/495-optimize-oversized-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m2s
2026-08-10 15:58:35 +00:00
timmy 95a14c6de5 feat: optimize oversized mobile screenshots (Closes #495)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:56:35 +00:00
timmy 37aa2dd090 Merge pull request 'Add a durable security activity center' (#494) from timmy/493-security-activity-center into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m6s
2026-08-10 15:39:50 +00:00
timmy 6565165295 feat: add durable security activity center (Closes #493)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:37:37 +00:00
timmy 4749d15d85 Merge pull request 'Use binary screenshot storage and upload end to end' (#492) from timmy/491-binary-screenshot-transport into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m2s
2026-08-10 15:06:07 +00:00
timmy 16604d697b feat: use binary screenshot transport (Closes #491)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 15:03:45 +00:00
timmy 59b4d5e6bf Merge pull request 'Keep mobile issue capture visible above the keyboard' (#490) from timmy/489-keyboard-safe-mobile-capture into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m3s
2026-08-10 14:28:57 +00:00
timmy 83af187c73 feat: keep mobile issue capture above keyboard (Closes #489)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:26:56 +00:00
timmy d4896e10be Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488) from timmy/487-searchable-repository-picker into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m1s
Merge pull request 'Add an explicit searchable repository picker for mobile capture' (#488)
2026-08-10 14:03:40 +00:00
timmy 81e1af7f16 feat: add explicit searchable repository picker (Closes #487)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 14:01:49 +00:00
timmy b1b3f758e4 Merge pull request 'Add device passkeys for operator access' (#486)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 59s
2026-08-10 13:16:21 +00:00
timmy ced644b275 feat: add device passkeys for operator access (Closes #485)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m3s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 13:14:18 +00:00
timmy 131848cc43 Merge pull request 'Require explicit authorization for queued issue closures' (#484) from timmy/483-authorize-queued-closures into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m1s
2026-08-10 12:30:59 +00:00
timmy 62a0841eb5 fix: authorize queued issue closures explicitly (Closes #483)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 12:29:02 +00:00
timmy cc63fc6499 Merge pull request 'Add an actionable offline delivery center' (#482) from timmy/481-offline-delivery-center into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m5s
2026-08-10 11:59:24 +00:00
timmy 9dede4bf2a feat: add offline delivery center (Closes #481)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 54s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 11:57:30 +00:00
timmy b667963968 Merge pull request 'Make background outbox claims lease-safe' (#480) from timmy/479-lease-safe-background-claims into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 55s
Merge pull request #480: Make background outbox claims lease-safe
2026-08-10 11:23:39 +00:00
timmy 31b0e79aa1 fix: fence background delivery claims (Closes #479)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 11:21:53 +00:00
timmy 8206856d94 Merge pull request 'Queue screenshot comments for offline delivery' (#478) from timmy/477-offline-screenshot-comments into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 58s
2026-08-10 10:57:51 +00:00
timmy 7ec3330175 feat: queue screenshot comments offline (Closes #477)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 58s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:56:02 +00:00
timmy ec339d242d Merge pull request 'Preserve screenshots while editing queued captures' (#476) from timmy/475-preserve-queued-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 53s
2026-08-10 10:36:15 +00:00
timmy d161cf41c0 fix: preserve screenshots while editing queued captures (Closes #475)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:34:34 +00:00
timmy 535658185a Merge pull request 'Keep offline screenshots outside localStorage quota' (#474) from timmy/473-indexeddb-screenshot-outbox into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 57s
2026-08-10 10:17:54 +00:00
timmy 8b7c56161c fix: move offline screenshots out of localStorage (Closes #473)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 59s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 10:15:58 +00:00
timmy 2b2e2cceb4 Merge pull request 'Make screenshot uploads retry-safe' (#472) from timmy/471-idempotent-attachment-uploads into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 53s
2026-08-10 09:56:37 +00:00
timmy de596f397f fix: make screenshot uploads retry-safe (#471)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:54:56 +00:00
timmy af86fc1e98 Merge pull request 'Attach screenshots while capturing mobile issues' (#470) from timmy/469-capture-issue-screenshots into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 53s
2026-08-10 09:36:49 +00:00
timmy 5bf491c725 feat: attach screenshots during issue capture (#469)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:35:09 +00:00
timmy 11341ec5d9 Merge pull request 'Attach screenshots to mobile issue comments' (#468) from timmy/467-mobile-comment-screenshots into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 54s
2026-08-10 08:57:08 +00:00
timmy eefb760169 feat: attach screenshots to issue comments (#467)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 53s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:55:13 +00:00
timmy 3f9dd49766 Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 58s
Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
2026-08-10 08:19:37 +00:00
timmy 72453b76db perf: adapt live polling to server freshness (#465)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 55s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:17:57 +00:00
timmy 7639a42d7b Merge pull request 'Lock inactive dashboard sessions without losing drafts' (#464) from timmy/463-idle-session-lock into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 54s
2026-08-10 07:47:07 +00:00
timmy 0b992543f1 feat: lock idle dashboard sessions (#463)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 56s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:45:00 +00:00
timmy 9904f17e3d Merge pull request 'Drain authored mutations before shutdown' (#462)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 53s
Closes #461
2026-08-10 07:14:28 +00:00
timmy 0dd3932d14 fix: drain authored actions on shutdown (#461)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:12:51 +00:00
timmy 8e6ee96ede Merge pull request 'Plan Today by available time and work estimates' (#460) from timmy/459-plan-today-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 52s
2026-08-10 06:55:04 +00:00
timmy 161977cb67 feat: plan Today by available time (#459)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:53:25 +00:00
rockachopa e3c07c2dfb Merge pull request 'Start deferred work directly from Later' (#458) from timmy/457-start-deferred-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 51s
2026-08-10 06:03:32 +00:00
timmy a610708e36 feat: start deferred work directly (#457)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:00:35 +00:00
timmy f8c5c2b058 Merge pull request 'Defer active Today work and continue to the next ready item' (#456) from timmy/455-defer-today-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 48s
2026-08-10 05:24:08 +00:00
timmy 72c470b652 feat: defer Today work and continue (#455)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 05:22:01 +00:00
timmy b214a49686 Merge pull request 'Share the Find Work catalog refresh across application workers' (#454) from timmy/453-shared-available-catalog into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 50s
2026-08-10 04:44:57 +00:00
timmy a9508984e2 fix: release cancelled catalog refresh leases (#453)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 50s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:43:25 +00:00
timmy 20d5176713 perf: share Find Work catalog across workers (#453)
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:40:15 +00:00
timmy c96ed51b0d Merge pull request 'Reply to updates and continue Today in one action' (#452)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 52s
2026-08-10 04:17:31 +00:00
timmy cf2e49300a feat: reply to updates and continue Today (#451)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:15:53 +00:00
timmy 5e49f27142 Merge pull request 'Continue Today after ownership exits' (#450) from timmy/449-ownership-exit-next into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 48s
2026-08-10 03:43:53 +00:00
timmy 0280077e45 feat: continue Today after ownership exits (#449)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 47s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:42:01 +00:00
timmy f781410e80 Merge pull request 'Autocomplete teammate mentions in mobile conversations' (#448) from timmy/447-mobile-mentions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 48s
2026-08-10 03:04:26 +00:00
timmy 0311f85d96 feat: autocomplete mobile teammate mentions (#447)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:02:35 +00:00
timmy 5a6ef62c4c Merge pull request 'Wrap mobile review diffs without horizontal panning' (#446) from timmy/445-wrap-mobile-review-diffs into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 52s
2026-08-10 02:39:38 +00:00
timmy 461c796227 feat: wrap mobile review diffs (#445)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 51s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:37:43 +00:00
timmy 6309ed43cd Merge pull request 'Work Today blockers without leaving Stackchain' (#444) from timmy/443-today-blocker-preview into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 50s
2026-08-10 02:07:06 +00:00
timmy a0aa3c0a22 feat: work Today blockers in-app (#443)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 52s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:05:11 +00:00
timmy 60bc7cd686 Merge pull request 'Bound Today readiness latency with demand-driven checks' (#442) from timmy/441-demand-driven-today-readiness into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 49s
2026-08-10 01:35:45 +00:00
timmy 79c8f1626b perf: bound Today readiness latency (#441)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:33:55 +00:00
timmy cd9f40a1c4 Keep notification acknowledgements responsive under snapshot contention (#440)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 51s
Closes #439
2026-08-10 01:16:02 +00:00
timmy 0a3fb180ed perf: batch notification snapshot maintenance (#439)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:14:11 +00:00
timmy 74d1435ca7 Merge pull request 'Skip blocked items while running the Today queue' (#438) from timmy/437-today-readiness into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 47s
2026-08-10 00:40:44 +00:00
timmy 8989e93c2c feat: skip blocked Today work (#437)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:39:13 +00:00
timmy 7b3b7dadb9 Merge pull request 'Surface blockers while planning Today work' (#436) from timmy/435-dependency-aware-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 48s
2026-08-10 00:02:47 +00:00
timmy 05f9e06a10 feat: surface Today issue blockers (#435)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 53s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:00:51 +00:00
timmy e0de45ea90 Merge pull request 'Coalesce live snapshot refreshes across application workers' (#434) from timmy/433-shared-live-snapshot into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 49s
2026-08-09 23:21:58 +00:00
timmy 3224389441 perf: share live refreshes across workers (#433)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 23:20:07 +00:00
timmy 63723e7fa3 Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 46s
Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
2026-08-09 22:22:03 +00:00
timmy 740e91a615 perf: precompress immutable dashboard runtime (#431)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 45s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:12:00 +00:00
timmy 0363f23cef Merge pull request 'Comment and continue to the next Today item' (#430) from timmy/429-comment-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 46s
2026-08-09 22:02:02 +00:00
timmy 2cbeba30fd feat: comment and continue Today work (#429)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 50s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:00:14 +00:00
timmy 43595de52f Merge pull request 'Queue offline issue closure and continue Today' (#428) from timmy/427-offline-close-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 44s
2026-08-09 21:27:59 +00:00
timmy c451cd0bcb feat: queue offline issue closure and continue Today (#427)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 21:26:23 +00:00
timmy ad27db6846 Merge pull request 'Restore background delivery after sign-in following outbox purge' (#426) from timmy/425-resume-outbox-after-purge into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 46s
2026-08-09 20:46:12 +00:00
timmy 791082bae2 fix: resume background delivery after purge (#425)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 47s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:44:26 +00:00
timmy d3ed0cca0c Merge pull request 'Queue offline Today reviews and continue to the next item' (#424) from timmy/423-queue-offline-review-next into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 52s
2026-08-09 20:06:04 +00:00
timmy ccb595a081 feat: queue offline Today reviews and continue (#423)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:04:20 +00:00
timmy 7b1c9a608c Merge pull request 'Finish Today items without changing Gitea' (#422) from timmy/421-done-today-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 44s
2026-08-09 19:39:15 +00:00
timmy 854d040e88 feat: finish Today items without changing Gitea (#421)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:37:40 +00:00
timmy 95baa20eeb Merge pull request 'Queue completed pull request reviews for reconnect delivery' (#420)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 48s
Closes #419
2026-08-09 19:06:15 +00:00
timmy 0cf88d7d7a feat: queue offline pull request reviews (#419)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 49s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:04:28 +00:00
rockachopa 969d5567d4 Merge pull request 'Compress the mobile dashboard shell over HTTP' (#418) from timmy/417-http-compression into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 46s
2026-08-09 18:35:26 +00:00
timmy 679639394f feat: compress dashboard shell over HTTP (#417)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 47s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 18:33:40 +00:00
rockachopa 3b374cbb50 Merge pull request 'Queue available work in Today without interrupting the current session' (#416)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 49s
Closes #415
2026-08-09 18:00:05 +00:00
timmy ad5e50eb48 feat: queue available work in Today (#415)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 42s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:57:48 +00:00
rockachopa 4b8d0c4504 Merge pull request 'Assign and start work from global search' (#414) from timmy/413-search-assign-and-start into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 45s
2026-08-09 17:08:02 +00:00
timmy 6e3b6804c1 feat: assign and start work from search (#413)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 48s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:06:16 +00:00
rockachopa c31129f1cc Merge pull request 'Assign available work and start it from Find' (#412) from timmy/411-assign-and-start into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 44s
2026-08-09 16:22:55 +00:00
timmy 4afe4dd3b9 feat: assign and start available work (#411)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 45s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 16:21:20 +00:00
rockachopa 985472eb06 Merge pull request 'Continue Today from the mobile Work dock' (#410) from timmy/409-continue-today-mobile-dock into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 47s
2026-08-09 15:44:22 +00:00
timmy 8b36455c21 feat: continue Today from mobile Work dock (#409)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 45s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 15:42:42 +00:00
rockachopa 3ba1fe76f0 Merge pull request 'Advance Today after merging a planned pull request' (#408) from timmy/407-merge-pr-and-advance-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 41s
Advance Today after merging a planned pull request (#408)

Closes #407
2026-08-09 14:56:39 +00:00
timmy 321474e8cf feat: advance Today after pull request merge (#407)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:55:03 +00:00
rockachopa 166d14dd35 Merge pull request 'Preview work before adding it to Today' (#406) from timmy/405-plan-today-preview into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 44s
2026-08-09 14:24:29 +00:00
timmy 7a62f7ead7 feat: preview work before planning (#405)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:22:37 +00:00
rockachopa 461f90c8ff Merge pull request 'Support issue capture across all accessible repositories' (#404) from timmy/403-issue-capture-repository-pagination into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 43s
2026-08-09 13:40:58 +00:00
timmy 9ea590a65c feat: paginate issue capture repositories (#403)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 45s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 13:39:27 +00:00
rockachopa 33a6d1bb6d Merge pull request 'Make Plan Today browser-back safe on mobile' (#402) from timmy/401-plan-today-back into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 42s
2026-08-09 12:40:56 +00:00
timmy b9ae9c6c83 feat: make Plan Today browser-back safe (#401)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 44s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:39:19 +00:00
rockachopa 45ceedb679 Merge pull request 'Expire offline private work with operator sessions' (#400) from timmy/399-offline-session-lease into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 42s
2026-08-09 12:06:15 +00:00
timmy 2e2a7821ae feat: expire offline work with sessions (#399)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 45s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:03:55 +00:00
rockachopa 33427d2407 Merge pull request 'Recover foreground dashboard actions from stalled requests' (#398) from timmy/397-foreground-request-deadlines into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 42s
2026-08-09 11:39:22 +00:00
timmy f0b0b703ff feat: bound foreground API requests (#397)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 41s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:37:57 +00:00
rockachopa 055a9dbb2a Merge pull request 'Resume interrupted Today sessions' (#396) from timmy/395-resume-today-session into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 43s
2026-08-09 11:03:45 +00:00
timmy 1f7fd093fd feat: resume interrupted Today sessions (#395)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:02:00 +00:00
rockachopa ff0e47cbe7 Merge pull request 'Collapse per-card planning actions on mobile' (#394) from timmy/393-mobile-card-planning-disclosure into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 40s
2026-08-09 10:38:25 +00:00
timmy c2da869c47 feat: collapse mobile card planning controls (#393)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 10:36:41 +00:00
rockachopa 8ada75ec33 Merge pull request 'Close issues and continue the Today session' (#392) from timmy/391-close-and-next into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 42s
2026-08-09 10:14:29 +00:00
timmy 6dea5fd1ef feat: close issues and continue Today session (#391)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 42s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 10:12:55 +00:00
rockachopa bf4ce3adad Merge pull request 'Keep Create & start durable across interruptions' (#390) from timmy/389-durable-create-and-start into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 41s
2026-08-09 09:42:52 +00:00
timmy 34664edb8b feat: keep create and start durable (#389)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 40s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 09:41:10 +00:00
timmy f92794f571 Merge pull request 'Create and start captured issues' (#388) from timmy/387-create-and-start into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 41s
2026-08-09 09:09:38 +00:00
timmy edbe2d76dd feat: create and start captured issues (#387)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 40s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 09:08:06 +00:00
timmy cbdddcddbe Merge pull request 'Plan Today in one focused mobile flow' (#386) from timmy/385-plan-today-mobile-flow into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 41s
2026-08-09 08:39:13 +00:00
timmy de6c9c249a feat: add focused mobile Today planner (#385)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 39s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 08:37:41 +00:00
rockachopa 19122c69e6 Merge pull request 'Make live snapshot revisions safe across workers and restarts' (#384) from timmy/383-multi-worker-live-revisions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 42s
2026-08-09 08:15:54 +00:00
timmy 2253c40577 fix: scope live revisions to worker generations (#383)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 41s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 08:14:18 +00:00
rockachopa 5c2c0383df Merge pull request 'Bound planning sync history and reject expired offline edits' (#382) from timmy/381-bound-planning-sync-history into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 41s
Closes #381
2026-08-09 07:45:32 +00:00
timmy 4e13f5482b feat: bound planning sync history (#381)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 39s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 07:43:53 +00:00
rockachopa 51278bc86f Merge pull request 'Bundle and fingerprint the mobile PWA runtime' (#380) from timmy/379-content-hashed-pwa-shell into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 43s
2026-08-09 07:00:36 +00:00
timmy a919242b8f perf: bundle and fingerprint the PWA runtime (#379)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 42s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:59:05 +00:00
rockachopa 56a9a0cba6 Merge pull request 'Keep Later plans conflict-safe across devices' (#378) from timmy/377-later-conflict-safe into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 38s
2026-08-09 06:39:03 +00:00
timmy 27fc74ced6 fix: keep Later plans conflict-safe across devices (#377)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 41s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:37:30 +00:00
rockachopa f686e2e71e Merge pull request 'Batch queued Today and Later planning edits' (#376)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 44s
Closes #375
2026-08-09 06:07:19 +00:00
timmy 8f7a836d28 feat: batch queued planning edits (#375)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 44s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 06:05:40 +00:00
rockachopa 5894eafb3f Merge pull request 'Keep Today and Later edits lossless across browser tabs' (#374) from timmy/373-lossless-planning-tabs into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 39s
2026-08-09 05:38:52 +00:00
timmy 639ce911be fix: keep planning edits lossless across tabs (#373)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 41s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 05:31:14 +00:00
rockachopa 475acbb92f Merge pull request 'Warn before duplicate mobile issue capture' (#372) from timmy/371-duplicate-aware-mobile-capture into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 40s
2026-08-09 04:43:22 +00:00
timmy c0fc4eddd0 feat: warn before duplicate mobile issue capture (#371)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 40s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 04:41:44 +00:00
rockachopa 1bf759dc76 Merge pull request 'Choose an exact return time for Later work' (#370) from timmy/369-exact-later-time into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 42s
2026-08-09 04:06:14 +00:00
timmy 9441191c18 feat: choose exact Later return times (#369)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 44s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 04:04:12 +00:00
rockachopa 76bea83537 Merge pull request 'Drain Today edits queued during an in-flight sync' (#368) from timmy/367-drain-today-inflight into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 40s
2026-08-09 03:35:59 +00:00
timmy 7775c3e1f6 fix: drain in-flight Today edits (#367)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 41s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 03:34:23 +00:00
timmy 98249ad9ed Merge pull request 'Retry Today and Later sync automatically after transient failures' (#366) from timmy/365-planning-sync-auto-retry into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 40s
2026-08-09 03:01:55 +00:00
timmy 901691a93b feat: retry planning sync automatically (#365)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 03:00:12 +00:00
rockachopa 6dfc02158d Merge pull request 'Sync Later deferrals across operator devices' (#364) from timmy/363-cross-device-later-sync into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 39s
2026-08-09 02:39:12 +00:00
timmy 454022ee89 feat: sync Later deferrals across devices (#363)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 43s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 02:37:32 +00:00
rockachopa 4bc2881a4b Merge pull request 'Keep Today plans convergent across tabs and devices' (#362) from timmy/361-convergent-today-sync into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 44s
Merge pull request 'Keep Today plans convergent across tabs and devices' (#362) from timmy/361-convergent-today-sync into main
2026-08-09 02:03:44 +00:00
timmy 3c743a2b71 feat: converge Today sync across active devices (#361)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 42s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 02:01:55 +00:00
timmy 7ec3980713 Merge pull request 'Retire completed work from the synced Today plan' (#360) from timmy/359-retire-completed-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 39s
2026-08-09 01:18:41 +00:00
timmy d940e0b37b fix: retire completed Today work across devices (#359)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 40s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 01:16:26 +00:00
timmy 4610afd489 Merge pull request 'Sync the ordered Today plan across operator devices' (#358) from timmy/357-cross-device-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 37s
2026-08-09 00:40:47 +00:00
timmy 020106f001 feat: sync Today plan across devices (#357)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 38s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 00:39:08 +00:00
timmy 3384e56cb8 Merge pull request 'Review saved pull request diffs and draft feedback offline' (#356) from timmy/355-offline-review-drafts into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 37s
2026-08-09 00:22:07 +00:00
timmy 229d112297 feat: draft requested reviews offline (#355)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 37s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 00:20:30 +00:00
timmy 34029e624f Merge pull request 'Open cached dashboard when mobile navigation stalls' (#354) from timmy/353-stalled-navigation-deadline into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 34s
2026-08-08 23:56:15 +00:00
timmy 0ff6b5c8fd fix: bound stalled dashboard navigations (#353)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 35s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 23:54:41 +00:00
timmy 391e8964c4 Merge pull request 'Finish unread-update triage offline with queued acknowledgements' (#352) from timmy/351-offline-update-acknowledgements into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 35s
2026-08-08 23:40:19 +00:00
timmy 990f0f04d6 feat: finish unread update triage offline (#351)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 35s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 23:38:52 +00:00
rockachopa a995e3b45f Merge pull request 'Keep the Today queue automatically ready offline' (#350) from timmy/349-offline-today-readiness into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 36s
2026-08-08 23:17:49 +00:00
timmy ac3d1f32d1 feat: keep Today work ready offline (#349)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 37s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 23:16:22 +00:00
timmy e11e55149c Merge pull request 'Continue unread-update conversations offline' (#348) from timmy/347-offline-update-conversations into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 36s
2026-08-08 22:43:48 +00:00
timmy ae6cde29c2 feat: continue unread updates offline (#347)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 36s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 22:42:20 +00:00
timmy 0539b0a035 Merge pull request 'Open saved Today details and queue replies offline' (#346) from timmy/345-offline-today-details into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 39s
2026-08-08 22:04:56 +00:00
timmy a2ba624dc2 feat: open saved Today details offline (#345)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 36s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 22:03:28 +00:00
rockachopa e78ceb1bf7 Merge pull request #344: Render safe mobile Markdown across My Work details
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 35s
2026-08-08 21:40:14 +00:00
timmy ac7a716e5c feat: render safe mobile Markdown across My Work (#343)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 33s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 21:38:59 +00:00
timmy fa4ad28961 Merge pull request 'Require fresh authorization for high-impact actions' (#342) from timmy/341-fresh-authorization into main
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 34s
2026-08-08 21:06:04 +00:00
timmy 3726360baa security: require fresh authorization for high-impact actions (#341)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 35s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 21:04:28 +00:00
timmy 5d326c30a1 Merge pull request #340: Gate RCs on verifiable artifacts
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 18s
CI / lint (push) Successful in 34s
Closes #339
2026-08-08 20:37:35 +00:00
timmy 2cb92811c9 ci: gate RCs on verifiable artifacts (#339)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 33s
CI / release-candidate (pull_request) Has been skipped
2026-08-08 20:35:52 +00:00
timmy 9e68382bd7 Merge pull request 'Purge private data after remote session revocation' (#338) from timmy/337-purge-private-data-after-remote-revocation into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 35s
2026-08-08 20:18:39 +00:00
timmy b4dc785dd8 security: purge data after remote session revocation (#337)
CI / build-frontend (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 37s
2026-08-08 20:17:13 +00:00
rockachopa a9051d843c Merge pull request 'Prevent overlapping autonomous release runs' (#336) from timmy/335-prevent-overlapping-autonomous-release-runs-with into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 34s
2026-08-08 19:55:48 +00:00
timmy 4969cadd82 feat: serialize autonomous release runs (#335)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 35s
2026-08-08 19:54:20 +00:00
timmy bf96560831 Merge pull request 'Isolate autonomous release subprocesses from parent credentials' (#334) from timmy/333-isolate-autonomous-release-subprocesses-from-par into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 30s
2026-08-08 19:36:00 +00:00
timmy 7342b2e6de security: isolate release subprocess credentials (#333)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 31s
2026-08-08 19:34:43 +00:00
rockachopa 671c88d287 Merge pull request 'Resume autonomous release runs from durable checkpoints' (#332) from timmy/331-resume-autonomous-release-runs-from-durable-chec into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 34s
2026-08-08 19:19:42 +00:00
timmy 503d6f4988 feat: resume release runs from checkpoints (#331)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 33s
2026-08-08 19:18:27 +00:00
rockachopa eb2c84a6c2 Bound autonomous release commands with process-tree deadlines (#330)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 32s
Closes #329
2026-08-08 18:55:29 +00:00
timmy fb6b42bcaf feat: bound autonomous release command runtime (#329)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 31s
2026-08-08 18:54:08 +00:00
rockachopa 41eaf51953 Merge pull request #328: Bound inbound request bodies and redact rejected input
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 33s
Closes #327
2026-08-08 18:36:24 +00:00
timmy c9c5939f0a security: bound and redact inbound requests (#327)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 33s
2026-08-08 18:34:48 +00:00
rockachopa 34af4ce903 Merge pull request 'Review and selectively revoke active operator devices' (#326) from timmy/325-active-operator-devices into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 34s
2026-08-08 18:01:26 +00:00
timmy 7034ec55ed feat: review and revoke active devices (#325)
CI / build-frontend (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 34s
2026-08-08 17:59:17 +00:00
timmy d46d296e09 Merge pull request 'Keep mobile conversation replies keyboard-safe' (#324) from timmy/323-mobile-conversation-keyboard-safe into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 33s
2026-08-08 17:35:29 +00:00
timmy 21e0a49d3c feat: keep mobile conversation replies keyboard-safe (#323)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 31s
2026-08-08 17:34:11 +00:00
timmy 160753caf8 Merge pull request 'Take ownership from unread issue updates' (#322) from timmy/321-take-ownership-unread-update into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 31s
2026-08-08 17:20:47 +00:00
timmy 0ea264a942 feat: claim work from unread updates (#321)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 32s
2026-08-08 17:19:13 +00:00
rockachopa d6c7a495ed Merge pull request 'Recover orphaned authored operations safely' (#320) from timmy/319-recover-orphaned-authored-operations into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 32s
2026-08-08 16:41:43 +00:00
timmy dc18004e02 fix: recover orphaned authored operations (#319)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 32s
2026-08-08 16:40:31 +00:00
rockachopa b5858046ee Merge pull request 'Make mobile work search keyboard-safe' (#318) from timmy/317-mobile-search-keyboard-safe into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 34s
2026-08-08 16:16:00 +00:00
timmy b5b6b4d0a9 feat: make mobile work search keyboard-safe (#317)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 34s
2026-08-08 16:14:29 +00:00
rockachopa 6143568b95 Merge pull request 'Make mobile task overlays browser-back safe' (#316) from timmy/315-mobile-overlay-back-safety into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 28s
2026-08-08 15:56:38 +00:00
timmy 9a152c435e feat: make mobile task overlays back-safe (#315)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 30s
2026-08-08 15:55:17 +00:00
timmy 80a1fa60bd Merge pull request 'Preserve mobile share capture through sign-in' (#314) from timmy/313-preserve-mobile-share-sign-in into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 31s
2026-08-08 15:20:01 +00:00
timmy 8ba4d48549 feat: preserve mobile shares through sign-in (#313)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 29s
2026-08-08 15:18:30 +00:00
timmy c0b42b71cf Merge pull request #312: Keep authentication database writes off the event loop
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 32s
Closes #311
2026-08-08 14:57:59 +00:00
timmy d026bbd400 perf: isolate authentication database work (#311)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 31s
2026-08-08 14:56:45 +00:00
timmy 9189d13db9 Merge pull request 'Recover safely when an active dashboard session expires' (#310) from timmy/309-session-expiry-recovery into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 30s
2026-08-08 14:33:46 +00:00
timmy 9786724678 fix: recover expired dashboard sessions (#309)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 30s
2026-08-08 14:32:37 +00:00
rockachopa 6b99d4f171 Merge pull request 'Prevent issue content from executing shell commands in the release worker' (#308) from timmy/307-prevent-issue-content-from-executing-shell-comma into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 30s
2026-08-08 14:15:08 +00:00
timmy 7d633e67e8 security: isolate release issue content from shell execution (#307)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 29s
2026-08-08 14:13:56 +00:00
timmy bd5e66b4e5 Merge pull request 'Recover queued delivery when background requests stall' (#306) from timmy/305-background-request-deadlines into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 29s
2026-08-08 13:48:06 +00:00
timmy add45365ab fix: recover stalled background delivery (#305)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 30s
2026-08-08 13:46:46 +00:00
rockachopa 6138b1f24e Merge pull request 'Make live refresh self-healing when browser requests stall' (#304)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 29s
Closes #303
2026-08-08 13:16:28 +00:00
timmy 40c66df143 fix: recover live refresh from stalled requests (#303)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 31s
2026-08-08 13:15:20 +00:00
rockachopa 7356530318 Merge pull request 'Capture unfiled work from a cold offline mobile launch' (#302) from timmy/301-cold-offline-capture into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 29s
2026-08-08 12:40:24 +00:00
timmy 9f6476a5b4 feat: capture cold-offline work for filing (#301)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 30s
2026-08-08 12:38:41 +00:00
timmy de8227f4f4 Merge pull request 'Show priority work above the fold on mobile launch' (#300) from timmy/299-mobile-above-fold into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 33s
2026-08-08 12:19:58 +00:00
timmy ccf5f69400 feat: show priority work above fold on mobile (#299)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 31s
2026-08-08 12:18:24 +00:00
timmy 207df76b69 Merge pull request 'Drain closed-app queued work concurrently without failure starvation' (#298) from timmy/297-bounded-background-drain into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 27s
2026-08-08 11:57:09 +00:00
timmy 43c7a85eec perf: drain background outbox concurrently (#297)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 27s
2026-08-08 11:55:58 +00:00
rockachopa d0b8c3dc0d Enforce a strict browser execution boundary (#296)
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 6s
CI / lint (push) Successful in 27s
Closes #295
2026-08-08 11:39:23 +00:00
timmy 6e6e63e553 security: enforce strict browser execution boundary (#295)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 29s
2026-08-08 11:38:18 +00:00
timmy 54029d2668 Merge pull request 'Complete private outbox purge before device sign-out' (#294) from timmy/293-complete-device-outbox-purge into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 27s
2026-08-08 11:16:13 +00:00
timmy 1f57b75d5d fix: complete private outbox purge before sign-out (#293)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 28s
2026-08-08 11:15:13 +00:00
rockachopa fefbe7e084 Merge pull request 'Make Stackchain installable from the mobile dashboard' (#292) from timmy/291-mobile-install-flow into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 27s
2026-08-08 10:58:46 +00:00
timmy 31a07e4a46 feat: add mobile install flow
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 28s
Closes #291
2026-08-08 10:55:35 +00:00
timmy b458dbaa78 Merge pull request 'Keep retained mobile planning available through refresh outages' (#290) from timmy/289-retained-planning into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 28s
2026-08-08 10:22:06 +00:00
timmy 8c4e729c4a fix: keep retained mobile planning available (#289)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 29s
2026-08-08 10:20:42 +00:00
timmy 80417abd5b Merge pull request 'Sign out all operator sessions from a lost-device safety action' (#288) from timmy/287-sign-out-all-devices into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 28s
2026-08-08 09:56:45 +00:00
timmy 37d55e9b20 feat: sign out all operator sessions (#287)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 28s
2026-08-08 09:55:34 +00:00
timmy 94cbde3cf9 Merge pull request 'Skip unchanged live snapshot sections end to end' (#286) from timmy/285-live-section-revisions into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 26s
2026-08-08 09:37:09 +00:00
timmy fedddb246d perf: skip unchanged live snapshot sections (#285)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 25s
2026-08-08 09:35:48 +00:00
timmy e28472b268 Retry edited offline issue captures with a fresh idempotency key (#284)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 28s
Closes #283
2026-08-08 09:01:35 +00:00
timmy 3b85b4f35d fix: rotate idempotency keys after offline issue edits (#283)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 29s
2026-08-08 09:00:21 +00:00
rockachopa 997cb7dcd7 Merge pull request 'Hand off assigned issues to teammates from mobile' (#282) from timmy/281-mobile-issue-handoff into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 28s
2026-08-08 08:38:54 +00:00
timmy 04bb6839ae feat: hand off assigned issues from mobile (#281)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 28s
2026-08-08 08:37:48 +00:00
rockachopa 1fdf6f417d Merge pull request 'Plan and run an ordered mobile Today queue' (#280) from timmy/279-mobile-today-queue into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 27s
2026-08-08 08:01:26 +00:00
timmy cc38b3d0f6 feat: add ordered mobile Today queue (#279)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 30s
2026-08-08 07:58:49 +00:00
timmy b11ec6a732 Merge pull request 'Reconcile ambiguous merge outcomes after upstream timeouts' (#278) from timmy/277-reconcile-merge-timeouts into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 27s
2026-08-08 07:34:43 +00:00
timmy e58808f2d4 fix: reconcile ambiguous merge outcomes (#277)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 26s
2026-08-08 07:33:38 +00:00
timmy e0543b2007 Keep operator session validation off the event loop (#276)
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 26s
Closes #275
2026-08-08 07:16:55 +00:00
157 changed files with 36946 additions and 3863 deletions

No files matched your search

+61 -5
View File
@@ -17,13 +17,69 @@ jobs:
- run: pip install -r requirements.txt
- run: python3 -m pytest tests/ -q
build-frontend:
build-release:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- name: Pack frontend
run: tar -czf frontend.tar.gz frontend
- name: Upload artifact
- name: Build deterministic release bundle
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
python3 scripts/build_release.py \
--root . \
--output-dir dist \
--commit "$GITHUB_SHA" \
--source-date-epoch "$SOURCE_DATE_EPOCH"
- name: Upload tested release bundle
uses: actions/upload-artifact@v3
with: { name: frontend, path: frontend.tar.gz }
with:
name: release-bundle
path: dist/
release-candidate:
runs-on: ubuntu-latest
needs: [lint, build-release]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download tested release bundle
uses: actions/download-artifact@v3
with:
name: release-bundle
path: dist
- name: Verify and publish release candidate
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TARGET="${{ github.sha }}"
TAG="v0.1.0-rc.${{ github.run_number }}"
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
(cd dist && sha256sum -c ./*.sha256)
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/release.json > /tmp/release-response.json
RELEASE_ID="$(python3 -c 'import json; print(json.load(open("/tmp/release-response.json"))["id"])')"
for ASSET in dist/*; do
NAME="$(basename "$ASSET")"
ENCODED_NAME="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")"
curl --fail-with-body -sS -X POST "$RELEASE_URL/$RELEASE_ID/assets?name=$ENCODED_NAME" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$ASSET"
done
printf '{"draft":false,"prerelease":true}\n' > /tmp/publish.json
curl --fail-with-body -sS -X PATCH "$RELEASE_URL/$RELEASE_ID" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/publish.json
-40
View File
@@ -1,40 +0,0 @@
name: Release
on:
push:
branches: [main]
workflow_dispatch:
jobs:
release-candidate:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Gitea tag and release candidate
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
TAG="v0.1.0-rc.${{ github.run_number }}"
TARGET="${{ github.sha }}"
TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags"
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \
"$TAG" "$TARGET" "$TAG" > /tmp/tag.json
curl --fail-with-body -sS -X POST "$TAG_URL" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/tag.json
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @/tmp/release.json
+324 -33
View File
@@ -19,21 +19,119 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue**
sheet accepts the same image formats and stores the screenshot with its account-bound
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
queued base64 screenshot payloads remain readable and are converted only at delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
creation and assignment permission. The New issue sheet can optionally select an open
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
touch or keyboard selection inserts the login without leaving the draft. Mention
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
creation and assignment permission. A fresh capture never silently targets the first repository:
the operator must explicitly choose one, either from the paginated browser or through the bounded
authenticated repository search. Search results include only visible repository identities, stale
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
repository and meaningful title are selected,
the New issue sheet checks for similar open issues in that repository. Candidate links keep
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
when search is unavailable. The sheet can also optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. Issue capture and authored mobile actions (issue
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
account-bound title/description captures without selecting a repository or entering the
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
the same Gitea login, **Choose repository** restores the capture to the normal planning
and durable delivery flow. A different or unconfirmed account can only copy or discard
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. **Reply & next** provides the same one-action continuation
for the current unread-update conversation. It deliberately leaves the notification unread;
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. Planning edits can remain offline for up to 30 days. After that, the
expired edit is discarded visibly and the account plan is kept rather than replaying stale
intent. The server retains no more than 4,096 operation receipts per account and removes
receipts older than the same 30-day window; client base revisions keep a pruned replay from
changing a newer Today or Later plan. After a healthy, fully paginated
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
queues an idempotent retirement before removing it locally; partial and degraded refreshes
leave the plan unchanged, and offline retirements replay after reconnect. When
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
`updated_at` revisions make no detail request, transient failures retain the prior copy,
and pull-request diffs remain online-only. Saved unread-update conversations remain
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
refresh still run immediately; a successful response resets transport backoff.
Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
is ready. Claims have unique fencing tokens and are renewed before every network stage;
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
writes run outside the request event loop, and lock admission is bounded to 100 ms by
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
five seconds to finish and persist their ledger result before it closes the Gitea transport.
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
shutdown budget; when that deadline expires, remaining operations are cancelled and their
pending reservations continue to fail closed rather than being retried automatically.
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
If contention occurs after the upstream mutation, the dashboard fails closed with
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
@@ -42,7 +140,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
request. On phone-width review sheets, changed lines wrap inside a stable old/new
line-number gutter by default so long source lines remain readable and commentable
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
horizontal inspection, and an explicit choice persists on the device across files,
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
@@ -61,12 +164,25 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
# Recommended behind a proxy; WebAuthn assertions must match these public values.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
# Background polling and queued delivery do not. Defaults to 15 minutes.
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Public passkey ceremonies share the same window and durable source ledger.
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
# Bound live one-time challenges even if anonymous clients rotate addresses.
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'
# Trust forwarding headers only from these immediate reverse-proxy networks.
@@ -74,25 +190,101 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
uvicorn src.main:app --host 127.0.0.1 --port 8000
```
Sign-in failures are scoped to a hashed canonical client address and persisted across
workers and restarts. Once the budget is exhausted, the server returns `429` with
`Retry-After`; the mobile login form disables retries for that interval. Expired
source records are pruned and the ledger is size-bounded. Keep its SQLite file on
shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer is
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
that setting, a reverse proxy is safely treated as one shared source.
Token and passkey sign-in failures are scoped to a hashed canonical client address and
persisted across workers and restarts. Public passkey option issuance has a separate
fixed-window admission budget in the same ledger, and live challenges are bounded per
source and globally in the session registry. Once either sign-in budget is exhausted,
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
failure state. Expired source records and challenges are pruned, and both ledgers are
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
networks you operate. Without that setting, a reverse proxy is safely treated as one
shared source.
Inbound API mutations are admitted through a body-size boundary before FastAPI
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
requests under `/api/v1/` are capped at 64 KiB. Both declared and streamed bodies
are counted. Oversized requests receive a compact, non-cacheable HTTP 413 response;
validation errors expose field locations and messages but never echo submitted
values or validation context. Preserve these limits at the reverse proxy or enforce
equal or tighter upstream limits.
Each signed cookie includes an opaque session identifier whose hash and expiry are
kept in the SQLite session registry. Keep that registry on persistent, writable
storage shared by all dashboard workers. Sign-out revokes only the current session
before clearing browser state, so a copied cookie cannot be replayed afterward;
other signed-in devices remain active. Deploying this version invalidates older
cookies that do not contain a registered identifier, so operators must sign in once
again. Registry read or write failures return a sanitized HTTP 503 before Gitea is
contacted.
storage shared by all dashboard workers. Operators name a device at sign-in and can
open **Active devices** to review creation/expiry times, identify the current device,
and revoke one remote session without interrupting other trusted devices. The API
exposes only independent management IDs and bounded labels—never cookie values,
session hashes, CSRF proofs, or source addresses. The registry also stores each
session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration.
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,
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
revocation, or issue closure, the journal durably reserves a pending event; if that
reservation fails, the consequential action does not begin. A successful action
remains truthfully reported even if its event cannot immediately be finalized, and
the activity sheet marks that durable record as **Outcome confirmation pending**.
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
WebAuthn credential with required user verification. That device can then sign in
and authorize high-impact actions with its biometric/PIN gesture. The access token
remains the recovery fallback for browsers without WebAuthn or devices without an
enrolled credential. Registration and authentication challenges are exact-purpose,
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
including those whose original session has expired or signed out, using only its
bounded label, enrollment time, and active/current status. Removing one requires a
single-use fresh authorization bound to that exact management ID. A remote active
session linked to the credential is revoked atomically; unrelated credentials and
sessions remain valid. Removing the current device's passkey keeps its current
session active, so the sheet warns that the recovery token will be required after
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
normally keeps the passkey available for the next sign-in.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the
operator access token again.
The server issues a random 90-second grant bound to the active session, exact action,
and exact target. Only its digest is stored, and the grant is consumed atomically on
first use. Expired, replayed, cross-session, and target-substituted grants fail before
Gitea or session state is changed. The browser preserves the pending request and
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
After 15 minutes without pointer, keyboard, or touch activity (configurable through
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
polling or background delivery continued. The dashboard locks and background outbox
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
the device; signing in resumes the existing account-bound work. If an active session
expires, the first authenticated API rejection replaces the
dashboard with sign-in and explains that private drafts remain on the device; signing
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
state. A selectively or globally revoked device instead receives a bounded revocation
reason: on its next server contact, Stackchain clears its owned local/session storage,
private outbox database, and dashboard caches before enabling sign-in. Revocation
blocks server access immediately, but no web application can erase a device that
remains offline forever. **Sign out & clear this device** revokes only the current
session before clearing browser state, so a copied cookie cannot be replayed
afterward; other signed-in devices remain active. **Sign out all devices** is a
separately confirmed lost-device safety action that atomically revokes every existing
operator session before clearing the current browser and returning to sign-in.
Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted.
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
Every response also defines the browser execution boundary with a Content Security
Policy that allows scripts only from the dashboard origin, denies framing and
plugins, and blocks unused browser capabilities. Keep these response headers when
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
is assembled in source order into one content-addressed JavaScript response. Dashboard
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.
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
@@ -133,6 +325,33 @@ other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
Live snapshots expose independent `context`, `events`, and `notifications`
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
atomically through a private SQLite store shared by all application workers. An expiring
refresh lease ensures only one worker loads currently due sections; other workers serve the
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
worker-shared writable storage. The database and directory are restricted to the service
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
an assignment invalidates the shared catalog so the newly available issue can be discovered
by the next authoritative scan. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
cannot suppress different content. The browser sends its known tokens on later polls, so
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the sections
that changed. Malformed or oversized tokens are rejected before any upstream work.
Confirmed notification acknowledgements update this worker's live view immediately, then
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
all successful notification IDs in one SQLite transaction and advance the notification
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
API keeps the confirmed result and process-local filtering rather than falsely reporting the
upstream write as failed; a later shared refresh reconciles the cache.
## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
@@ -141,13 +360,23 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
My Work also has a local **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
accepts a valid future local date and time and returns the item at that exact instant; the picker
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later state is stored only in this browser, scoped to the confirmed Gitea
login, and removed when fully loaded work confirms that an item no longer exists.
existing priority position at the wake time. **Start now** atomically admits a deferred
item to Today, removes its Later record, and opens that exact item in a resumable Today
session; full or unavailable Today storage leaves the deferral intact for retry. Items
already in Today are opened without duplication. **Bring back now** still restores an
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
and replay after reconnect. Each queued edit carries the account revision it was based
on; if another device has since changed the same item, Stackchain keeps the newer
account plan and reports the conflict instead of silently overwriting it. Unrelated
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
default durable store at `.stackchain-state/later.sqlite3`.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
@@ -160,18 +389,40 @@ dashboard keeps polling until it can restore its live snapshot automatically.
Users can explicitly enable **Keep My Work available offline**. Each healthy live
refresh then stores a seven-day, versioned snapshot containing only the signed-in
user identity and queue-card metadata for issues, pull requests, unread updates,
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
events, and complete API responses are excluded. A cold offline launch labels the
saved time and renders this snapshot read-only; opening live details, pagination,
and server mutations remain disabled until reconnection. **Clear offline work
data** deletes the snapshot, and opting out deletes it automatically.
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
newest 20 comments. Requested-review records also retain the head SHA, CI state,
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
per file. A previously opened unread update is retained by notification
identity with allowlisted subject context and its newest 20 conversation messages.
The snapshot and each account-bound detail are committed as separate IndexedDB records,
so large review previews do not consume the small synchronous localStorage quota or require
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
patches, and complete API responses are excluded.
A cold offline launch labels the saved time. Cached Today details open in the existing
phone sheet, where comments can enter the account-bound durable outbox. For issue and
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
**Queue review for reconnect** durably stores that complete SHA-bound review with one
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
queued operation and its matching SHA-scoped draft and progress.
Cached unread updates use the same phone conversation sheet and replies enter the
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
Cards without a saved detail explain that reconnection is required. **Clear offline
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
IndexedDB records; seven-day expiry removes stale records automatically.
API responses and mutations are never cached by the service worker. New issue captures,
issue comments, pull-request comments, and unread-update replies use bounded local
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
new issues, issue comments, pull-request comments, unread-update replies, and completed
pull-request reviews after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
@@ -233,6 +484,10 @@ python3 -m src.release_engine \
--repo stackchain/stackchain-dashboard \
--agent timmy \
--issue 19 \
--agent-timeout 1800 \
--test-timeout 900 \
--termination-grace 5 \
--agent-env AGENT_CONFIG_HOME \
--test-command 'python3 -m pytest tests/ -q'
```
@@ -241,6 +496,42 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
do not run without `--issue` when processing a preselected ticket.
Durable state defaults to `.release-engine/state.json`. Full behavior and
safety gates are documented in
Agent and test commands use POSIX argument quoting and run as argument vectors,
not through a shell. Issue context is available to the agent only through
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
text. Shell substitutions, redirects, and pipelines are intentionally not
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
configure that script as `RELEASE_AGENT_COMMAND` instead.
Every child process receives an explicit least-privilege environment rather
than inheriting the worker's credentials. Coding agents receive a minimal
runtime environment plus the five `RELEASE_*` context values above. Tests and
Git receive only the minimal runtime environment, so `GITEA_TOKEN` and
unrelated parent secrets remain in the release-engine process. If an agent
runtime needs another non-secret setting, allow it explicitly with a repeated
`--agent-env NAME` option or the comma-separated `RELEASE_AGENT_ENV` variable.
`GITEA_TOKEN`, reserved `RELEASE_*` names, and malformed names are rejected
before the issue is claimed. Secret-bearing values must not be allowlisted.
Agent and test deadlines are independent. On expiry, the engine terminates the
command's entire process group, escalates from `SIGTERM` to `SIGKILL` after the
configured grace period, and records `agent_timed_out` or `tests_timed_out`
without advancing the release. The equivalent environment variables are
`RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and
`RELEASE_TERMINATION_GRACE`; every value must be positive.
Durable state defaults to `.release-engine/state.json`. Successful checkpoints
record the repository, issue branch, and exact commit SHA. A restart resumes at
tests, push, or PR creation only after validating that identity and a clean
worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations
are serialized by an OS-backed, non-blocking lease at
`.release-engine/run.lock` under the canonical repository path. If an hourly
run overlaps a manual or slow prior run, the contender exits with `release run
already active` and owner diagnostics before Gitea discovery or any Git/agent
work. The kernel releases ownership when the process exits, so never delete a
lock file to recover from a crash; stale file metadata cannot retain the lease.
Checkpoint replacement uses writer-unique, flushed temporary files and an
atomic rename, preventing concurrent writers from colliding or exposing partial
JSON. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
+56 -4
View File
@@ -12,15 +12,29 @@ Convert one eligible Gitea issue into a tested, traceable pull request without d
- Repository key (`owner/repo`).
- Agent username.
- Optional explicit issue number; otherwise deterministic queue selection.
- Coding-agent command template.
- Test command.
- Coding-agent command, parsed into an argument vector.
- Test command, parsed into an argument vector.
- Positive, independent coding-agent and test deadlines plus a termination grace period.
- Optional explicit allowlist of non-secret parent environment variables needed by the coding-agent runtime.
- Local repository path and durable state-file path.
## State machine
`discovered → claimed → agent_complete → tests_passed → pushed → pr_opened`
Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
Terminal failure states include `claim_failed`, `agent_failed`, `agent_timed_out`, `tests_failed`, `tests_timed_out`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
Successful checkpoints are schema-versioned and bind the repository, issue,
deterministic branch, and exact commit SHA. On restart, the engine:
- resumes `agent_complete` at tests;
- resumes `tests_passed` at push; and
- resumes `pushed` at PR creation after verifying the remote branch SHA.
Before skipping any stage, it checks out the recorded branch, verifies its
local `HEAD`, and requires a clean worktree. Missing identity, an unsupported
schema, or any repository/issue/branch/SHA mismatch fails closed instead of
repeating or advancing delivery.
## Queue selection
@@ -30,6 +44,19 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
4. Otherwise sort by priority label (`P0`, `P1`, `P2`, unlabeled) then issue number.
5. Select exactly one issue per invocation.
## Single-active-run lease
- Before issue discovery, every invocation acquires a non-blocking advisory lock
at `.release-engine/run.lock` under the canonical repository path.
- The lease is held through the final checkpoint and pull-request request. A
contended invocation fails immediately with `release run already active`
before contacting Gitea or running Git, agent, or test commands.
- The lock file records bounded owner PID and acquisition-time diagnostics.
These are informational only: kernel lock ownership is authoritative, so a
killed or crashed process releases the lease automatically and stale metadata
cannot block the next run.
- Different canonical repository paths use independent leases.
## Claiming
- PATCH the issue with the configured assignee.
@@ -39,9 +66,18 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
## Execution
- Branch format: `<agent>/<issue>-<slug>`.
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
- Coding and test commands are parsed with POSIX argument quoting and executed directly without a shell.
- Issue number, title, body, repo, and branch are supplied to the coding agent only through `RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`, `RELEASE_REPO`, and `RELEASE_BRANCH` environment variables. Gitea-controlled content is never interpolated into executable syntax.
- Child processes never inherit the complete parent environment. Coding receives a minimal runtime environment, explicitly allowlisted non-secret agent variables, and the five issue-context values. Tests and Git receive only the minimal runtime environment; `GITEA_TOKEN` remains parent-process-only.
- Agent variables are allowlisted with repeated `--agent-env NAME` options or comma-separated `RELEASE_AGENT_ENV`. `GITEA_TOKEN`, `RELEASE_*`, and malformed names fail validation before claim.
- Shell operators, substitutions, and pipelines are not interpreted. Operators that are intentionally required must live in a separately reviewed wrapper script configured as the command.
- Empty or malformed commands abort before the issue claim.
- Non-zero coding-agent exit blocks tests and PR creation.
- Tests run using the configured command; stdout/stderr and exit code become evidence.
- Coding-agent and test commands each run in a new process session. Their deadlines default to 1800 and 900 seconds, respectively.
- On expiry, the engine sends `SIGTERM` to the command's entire process group, waits for the termination grace period (5 seconds by default), then sends `SIGKILL` to the group and reaps the command. This prevents descendants from keeping the worker wedged.
- Deadlines are configured with `--agent-timeout`, `--test-timeout`, and `--termination-grace`, or `RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and `RELEASE_TERMINATION_GRACE`. Non-positive values abort before issue discovery or claim.
- Timeout evidence is bounded in durable state. An agent timeout blocks tests; a test timeout blocks push and PR creation.
## PR and release gate
@@ -55,6 +91,12 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
- `--dry-run` performs discovery and planning only: no claim, git mutation, agent command, push, or PR.
- State is written atomically after each successful transition.
- Each checkpoint write uses a writer-unique temporary file, flushes file data,
atomically replaces the checkpoint, and flushes its parent directory. A
failed or concurrent writer therefore cannot collide on a shared temp path or
expose partial JSON.
- Resumable state carries repository and commit identity; successful stages are
skipped only after local (and, after push, remote) Git verification.
- One invocation handles at most one issue.
- Missing token, dirty worktree, failed claim verification, failed tests, or missing evidence blocks PR creation.
@@ -68,3 +110,13 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
6. Passing tests produce a linked PR request with evidence.
7. Existing state/PR prevents duplicate work.
8. Dry-run against live Gitea returns a plan and performs no mutation.
9. A timed-out command terminates its descendants and returns within its deadline plus grace period.
10. Agent and test timeouts persist distinct terminal states and block every later delivery stage.
11. Non-positive deadline configuration aborts before issue claim.
12. Restarts from `agent_complete`, `tests_passed`, and `pushed` skip only the
completed stages and open exactly one linked PR.
13. Dirty or identity-mismatched checkpoints stop before push or PR creation.
14. Parent credentials are absent from coding-agent, test, and Git subprocesses while explicit agent variables and issue context reach only the coding stage.
15. Reserved or malformed agent environment allowlist entries abort before claim.
16. An overlapping invocation fails before Gitea discovery or local command execution and reports bounded owner diagnostics.
17. Concurrent checkpoint-save stress completes without temp-file collisions or partial state.
+49
View File
@@ -0,0 +1,49 @@
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
let request = null;
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
if (request) return request;
if (!available()) {
announce('Today is full—remove an item before assigning this issue.');
return Promise.resolve('full');
}
request = Promise.resolve()
.then(() => alreadyOwned ? item : claim(item))
.then(confirmed => {
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
if (outcome === 'queued') {
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
'Assigned and queued in Today. Keep finding work when ready.');
return outcome;
}
if (outcome === 'started') {
announce(alreadyOwned ? 'Added to Today and ready to work.' :
'Assigned, added to Today, and ready to work.');
return outcome;
}
if (destination === 'queue') {
if (outcome === 'sync-unavailable') {
announce(alreadyOwned ?
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
} else {
announce(alreadyOwned ?
'Today could not be queued. The issue is open so you can recover.' :
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
}
} else {
announce(alreadyOwned ?
'Today could not start. The issue is open so you can recover.' :
'Assigned to you, but Today could not start. The issue is open so you can recover.');
}
recover(confirmed);
return 'recovery';
})
.finally(() => { request = null; });
return request;
}
return { run };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
+177 -14
View File
@@ -4,7 +4,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
function reviewFingerprint(message) {
return JSON.stringify({
body: String(message.body || ''),
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments : [],
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
if (item.draftKey && item.draftFingerprint &&
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
if (item.progressKey && item.progressFingerprint &&
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
}
function read() {
try {
@@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0) &&
item.expectedHeadSha === String(message.expectedHeadSha || ''));
if (queuedReview) {
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
@@ -45,6 +74,28 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
stored: true,
},
} : {}),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
draftKey: String(message.draftKey || ''),
progressKey: String(message.progressKey || ''),
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
...(message.kind === 'issue-blocker' ? {
blockerRepository: String(message.blockerRepository || ''),
blockerNumber: Number(message.blockerNumber || 0),
present: message.present === true,
} : {}),
};
items.push(item);
write(items, mirror);
@@ -53,11 +104,30 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
...candidate,
attachment: {
filename: String(message.attachment.filename || ''),
contentType: String(message.attachment.contentType || ''),
...(message.attachment.blob ? { blob: message.attachment.blob } :
{ data: String(message.attachment.data || '') }),
},
} : candidate);
try {
await backgroundSync.reconcile(durableItems, 'authored');
} catch (error) {
write(read().filter(candidate => candidate.id !== item.id), false);
throw error;
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
@@ -77,6 +147,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
status: 'queued',
};
delete updated.error;
delete updated.deliveryState;
return updated;
}));
return updated;
@@ -94,6 +165,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'issue-blocker') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
}
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
@@ -101,6 +181,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const attemptAt = Number(now());
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'sending', lastAttemptAt:attemptAt,
} : candidate), false);
const request = (async () => {
try {
let result;
@@ -113,27 +197,87 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
if (item.kind === 'update-reply-read') {
if (!item.replyConfirmed) {
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
write(read().map(candidate => candidate.id === item.id ? {
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
} : candidate), false);
}
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
} else if (item.kind === 'issue-close') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else if (item.kind === 'issue-blocker') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present,
}),
});
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
throw new Error('Blocker change was not confirmed.');
}
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : { body: item.body };
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify(body),
});
}
}
if (!result) return { blocked: true };
clearConfirmedReviewState(item);
discard(item.id);
return { result };
} catch (error) {
const status = Number(error?.status || 0);
const permanent = status >= 400 && status < 500;
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
if (permanent) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
lastAttemptAt: attemptAt,
lastAttemptError: attemptError,
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
} else {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
} : candidate));
}
return { error, transient: !permanent };
@@ -149,7 +293,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (item.status === 'attention' || item.kind === 'issue-close') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const outcome = await sendItem(item, currentLogin);
if (outcome.result) confirmed.push(outcome.result);
@@ -170,7 +314,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
const queued = item.status === 'attention' ? update(id, item) : item;
let queued = item;
if (item.status === 'attention') {
queued = {
...item,
operationId: String(makeId()).slice(0, 128),
status: 'queued',
};
delete queued.error;
delete queued.deliveryState;
write(read().map(candidate => candidate.id === id ? queued : candidate));
}
const outcome = await sendItem(queued, currentLogin);
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
}
@@ -184,11 +338,20 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
if (background?.status === 'authorization') return [{
...item,
status: 'authorization',
error: String(background.error || 'Fresh authorization required').slice(0, 240),
}];
return [item];
});
+546 -29
View File
@@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.result.createObjectStore('issues', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
databasePromise = undefined;
};
resolve(db);
};
request.onerror = () => reject(request.error);
});
return databasePromise;
@@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const transact = async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('issues', 'readwrite');
@@ -28,6 +35,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
Promise.resolve(work({
get: id => requested(objectStore.get(id)),
getAll: () => requested(objectStore.getAll()),
put: value => requested(objectStore.put(value)),
delete: id => requested(objectStore.delete(id)),
@@ -38,9 +46,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
});
});
};
transact.close = async () => {
if (!databasePromise) return;
const db = await databasePromise;
db.close();
databasePromise = undefined;
};
return transact;
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
function createIssueSyncStore({
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
createToken = () => globalThis.crypto?.randomUUID?.() ||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
} = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items, outboxLane = 'issue') {
@@ -51,15 +70,23 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (current.recordType === 'receipt-preference') continue;
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
let replacement = incoming.get(current.id);
if (!replacement) {
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
await records.delete(current.id);
}
continue;
}
if (current.operationId === replacement.operationId &&
(current.attachment?.data || current.attachment?.blob) &&
replacement.attachment?.stored &&
!replacement.attachment.data && !replacement.attachment.blob) {
replacement = { ...replacement, attachment: current.attachment };
incoming.set(current.id, replacement);
}
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && replacement.status === 'attention') ||
(['attention', 'authorization'].includes(current.status) &&
replacement.status === current.status) ||
current.status === 'sent') {
incoming.set(current.id, current);
}
@@ -73,15 +100,41 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const timestamp = Number(now());
const items = await records.getAll();
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
if (!item) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
const claimed = {
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
};
await records.put(claimed);
return claimed;
});
}
async function planBatch(ownerLogin, limit = 70) {
return transact(async records => {
const timestamp = Number(now());
const eligible = (await records.getAll()).filter(candidate =>
candidate.recordType !== 'receipt-preference' &&
candidate.ownerLogin === ownerLogin &&
candidate.kind !== 'issue-close' &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
const lanes = {
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
authored: eligible.filter(item => item.outboxLane === 'authored'),
};
const selected = [];
const maximum = Math.max(0, Number(limit) || 0);
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
}
return selected;
});
}
async function claim(id, ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
@@ -89,7 +142,9 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (!item || item.ownerLogin !== ownerLogin ||
!['queued', 'sending'].includes(item.status) ||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
const claimed = {
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
};
await records.put(claimed);
return claimed;
});
@@ -99,10 +154,19 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
return transact(async records => {
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
(['attention', 'authorization'].includes(current.status) &&
item.status === current.status) ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
? { attachment: current.attachment } : {};
const next = current && current.operationId === item.operationId ? {
...item, ...preservedAttachment,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
} : { ...item };
await records.put(next);
return next;
});
}
@@ -113,6 +177,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
async function updateClaim(id, claimToken, transform) {
return transact(async records => {
const item = records.get ? await records.get(id) :
(await records.getAll()).find(candidate => candidate.id === id);
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
return false;
}
await records.put(transform(item));
return true;
});
}
function clearClaim(item, changes) {
const { claimToken: _claimToken, ...unclaimed } = item;
return { ...unclaimed, ...changes, claimUntil: 0 };
}
async function renew(id, claimToken) {
let renewed = null;
await updateClaim(id, claimToken, item => {
renewed = { ...item, claimUntil: Number(now()) + claimMs };
return renewed;
});
return renewed;
}
function preferenceId(ownerLogin) {
return 'receipt-preference:' + String(ownerLogin || '').trim();
}
@@ -135,13 +225,29 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
}
return {
get: id => transact(records => records.get(id)),
reconcile,
upsert,
update,
claim,
claimNext,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
planBatch,
supportsClaimTokens: true,
renew,
checkpoint: updateClaim,
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
status: 'sent',
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
})),
release: (id, claimToken) => updateClaim(id, claimToken,
item => clearClaim(item, { status: 'queued' })),
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
status: 'attention', error,
...(deliveryState ? { deliveryState } : {}),
})),
authorization: (id, claimToken, error) => updateClaim(
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
),
snapshot: () => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
countBlocked: ownerLogin => transact(async records =>
@@ -149,18 +255,86 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
setReceiptPreference,
getReceiptPreference,
close: () => transact.close?.(),
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
function createBackgroundIssueSync({
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activePurge = null;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
const completeClaim = (item, delivered) => store.supportsClaimTokens
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
const releaseClaim = item => store.supportsClaimTokens
? store.release(item.id, item.claimToken) : store.release(item.id);
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
? store.fail(item.id, item.claimToken, error, deliveryState)
: store.fail(item.id, error, deliveryState);
const requireAuthorization = (item, error) => store.supportsClaimTokens
? store.authorization(item.id, item.claimToken, error)
: store.authorization(item.id, error);
const checkpointClaim = (item, transform) => store.supportsClaimTokens
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
async function requestStage(item, url, options) {
if (store.renew) {
const renewed = await store.renew(item.id, item.claimToken);
if (!renewed) throw new Error('Background delivery claim was lost.');
}
return requestJson(url, options);
}
async function requestJson(url, options = {}) {
const controller = new AbortController();
activeRequests.add(controller);
let timer;
const interrupted = new Promise((resolve, reject) => {
controller.signal.addEventListener('abort', () => {
reject(new Error(purgeRequested
? 'Background request canceled.'
: 'Background request timed out.'));
}, { once: true });
timer = setTimeout(() => controller.abort(), timeoutMs);
});
try {
return await Promise.race([
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
interrupted,
]);
} finally {
clearTimeout(timer);
activeRequests.delete(controller);
}
}
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
}
if (item.kind === 'update-reply') {
if (item.kind === 'notification-read') {
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
}
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-close') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@@ -169,10 +343,68 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
}
function deliveryRequest(item) {
if (item.kind === 'notification-read') {
return {
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
};
}
if (item.kind === 'update-reply') {
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
if (item.kind === 'update-reply-read') {
return {
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
};
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
options: {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
},
};
}
if (item.kind === 'issue-blocker') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
options: {
method: 'PATCH',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
repository: item.blockerRepository,
number: item.blockerNumber,
present: item.present === true,
}),
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments || [],
}),
},
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
@@ -215,29 +447,232 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
};
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function deliverReplyRead(item) {
let current = item;
if (!current.replyConfirmed) {
let attachmentMarkdown = current.attachmentMarkdown;
if (current.attachment && !attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
}
const text = String(current.body || '').trim();
const replyBody = attachmentMarkdown ?
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
const options = authoredRequest('', { ...current, body:replyBody }).options;
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
await requestStage(
current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
options,
);
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
current = { ...current, replyConfirmed: true };
}
const request = deliveryRequest(current);
return requestStage(current, request.url, request.options);
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function deliverIssueCapture(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let deliveredIssue = item.deliveredIssue;
if (!deliveredIssue) {
const request = deliveryRequest(item);
deliveredIssue = await requestStage(item, request.url, request.options);
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
}
await requestStage(
item,
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: attachmentMarkdown }),
},
);
return deliveredIssue;
}
async function deliverScreenshotComment(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(
item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
}
const text = String(item.body || '').trim();
return requestStage(
item,
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
},
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
},
);
}
async function deliverUpdateScreenshotReply(item) {
let current = item;
let attachmentMarkdown = current.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
method:'POST',
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
body:attachmentMultipart(current.attachment),
});
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
current = { ...current, attachmentMarkdown };
}
const text = String(current.body || '').trim();
return requestStage(current,
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json',
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
});
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await fetchJson(request.url, request.options);
await store.complete(item.id);
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
throw error;
}
if (item.kind === 'issue-blocker') {
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
Number(candidate?.number) === item.blockerNumber);
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
const error = new Error('Blocker change was not confirmed.');
error.status = 422;
throw error;
}
}
await completeClaim(item, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
} catch (error) {
const status = Number(error?.status || 0);
if (status === 401) {
await store.release(item.id);
await releaseClaim(item);
throw error;
}
if (status === 428 && item.kind === 'pull-review') {
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
await requireAuthorization(item, message);
return {
authorization: true,
error,
receipt: receiptFor(item, 'authorization'),
};
}
if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
await failClaim(
item,
String(error?.message || 'Issue needs attention').slice(0, 240),
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
);
return { attention: true, error, receipt: receiptFor(item, 'attention') };
}
await store.release(item.id);
await releaseClaim(item);
throw error;
}
}
async function send(item, currentLogin) {
if (purgeRequested) return { blocked: true };
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
await store.upsert(item);
const claimed = await store.claim(item.id, currentLogin);
@@ -245,30 +680,112 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return deliver(claimed);
}
async function flush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
async function runFlush() {
const identity = await requestJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
const login = String(identity?.login || '').trim();
const confirmed = [];
const receipts = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
while (true) {
const item = await store.claimNext(login);
if (!item) break;
const result = await deliver(item);
let authorization = 0;
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
const collect = result => {
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
if (result.authorization) authorization += 1;
if (result.receipt) receipts.push(result.receipt);
};
if (store.planBatch) {
const planned = await store.planBatch(login, batchSize);
let next = 0;
let authenticationError = null;
let transientError = null;
const worker = async () => {
while (!purgeRequested && !authenticationError && next < planned.length) {
const id = planned[next++];
const item = await store.claim(id, login);
if (!item) continue;
try {
collect(await deliver(item));
} catch (error) {
if (Number(error?.status || 0) === 401) authenticationError = error;
else if (!transientError) transientError = error;
}
}
};
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
await Promise.all(Array.from({ length: concurrency }, worker));
if (purgeRequested) throw new Error('Background delivery canceled.');
if (authenticationError) throw authenticationError;
if (transientError) throw transientError;
} else if (store.claimBatch) {
const claimed = await store.claimBatch(login, batchSize);
let next = 0;
let authenticationError = null;
let transientError = null;
const worker = async () => {
while (!purgeRequested && !authenticationError && next < claimed.length) {
const item = claimed[next++];
try {
collect(await deliver(item));
} catch (error) {
if (Number(error?.status || 0) === 401) authenticationError = error;
else if (!transientError) transientError = error;
}
}
};
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
await Promise.all(Array.from({ length: concurrency }, worker));
if (purgeRequested) {
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
throw new Error('Background delivery canceled.');
}
if (authenticationError) {
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
throw authenticationError;
}
if (transientError) throw transientError;
} else {
while (!purgeRequested) {
const item = await store.claimNext(login);
if (!item) break;
collect(await deliver(item));
}
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention, login, receipts };
return { confirmed, blocked, attention, authorization, login, receipts };
}
function flush() {
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
if (activeFlush) return activeFlush;
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
return activeFlush;
}
function purge() {
if (activePurge) return activePurge;
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
activePurge = (async () => {
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
})();
return activePurge;
}
async function resume() {
const pendingPurge = activePurge;
if (pendingPurge) await pendingPurge;
if (activePurge === pendingPurge) activePurge = null;
purgeRequested = false;
}
return {
flush, send,
flush, send, purge, resume,
get: id => store.get(id),
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
+39
View File
@@ -0,0 +1,39 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createCardPlanning = factory;
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
function sync(disclosure) {
disclosure.querySelector('summary')?.setAttribute(
'aria-expanded', String(disclosure.open)
);
}
function wire() {
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
disclosures.forEach(disclosure => {
if (disclosure.dataset.cardPlanningBound === 'true') return;
disclosure.dataset.cardPlanningBound = 'true';
sync(disclosure);
disclosure.addEventListener('toggle', () => {
if (disclosure.open) {
disclosures.forEach(other => {
if (other === disclosure || !other.open) return;
other.open = false;
sync(other);
});
}
sync(disclosure);
});
disclosure.addEventListener('keydown', event => {
if (event.key !== 'Escape' || !disclosure.open) return;
disclosure.open = false;
sync(disclosure);
disclosure.querySelector('summary')?.focus();
event.preventDefault();
event.stopPropagation();
});
});
}
return { wire };
});
+61
View File
@@ -0,0 +1,61 @@
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null;
async function admitQueued(item, message) {
const admission = await queue(message);
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
} catch (error) {
if (!canQueue(error)) throw error;
const identity = queueKind === 'update-reply' ? {
kind: 'update-reply', notificationId: item.notification_id,
} : {
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository, number: item.number,
};
return admitQueued(item, {
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
function admit(item, message) {
if (inFlight) return inFlight;
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
return inFlight;
}
return { submit, admit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
+126 -14
View File
@@ -1,3 +1,22 @@
function buildLiveRevisionQuery(revisions = {}) {
const params = new URLSearchParams();
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
['context', 'events', 'notifications'].forEach((section) => {
const revision = revisions[section];
if (typeof revision === 'string' && tokenPattern.test(revision)) {
params.set(section + '_revision', revision);
}
});
return params.toString();
}
function retryAfterMs(value) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.min(seconds * 1000, 300000);
}
function createContextPoller({
fetchContext,
onSnapshot,
@@ -5,56 +24,141 @@ function createContextPoller({
isHidden = () => false,
setTimer = setTimeout,
clearTimer = clearTimeout,
setDeadlineTimer = setTimeout,
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
maxBackoffMs = 60000,
}) {
let inFlight = null;
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
.filter(section => !section.degraded)
.map(section => freshForSeconds - Number(section.age_seconds))
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
}
return intervalMs;
}
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
timer = setTimer(() => {
timer = null;
refresh();
}, intervalMs);
}, delayMs);
}
function refresh() {
function abortError() {
const error = new Error('Live update superseded.');
error.name = 'AbortError';
return error;
}
function supersede(request) {
if (!request || request.settled) return;
request.superseded = true;
request.controller.abort();
request.rejectDeadline(abortError());
}
function refresh(options = {}) {
if (stopped || isHidden()) return Promise.resolve(null);
if (inFlight) return inFlight;
if (activeRequest && !options.force) return activeRequest.promise;
if (activeRequest) supersede(activeRequest);
const controller = new AbortController();
let rejectDeadline;
const deadlinePromise = new Promise((resolve, reject) => {
rejectDeadline = reject;
});
const requestState = {
controller,
deadline: null,
promise: null,
rejectDeadline,
settled: false,
superseded: false,
};
activeRequest = requestState;
requestState.deadline = setDeadlineTimer(() => {
if (requestState.settled || requestState.superseded) return;
controller.abort();
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
error.name = 'TimeoutError';
rejectDeadline(error);
}, timeoutMs);
let request;
try {
request = fetchContext();
request = fetchContext({ ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
inFlight = Promise.resolve(request)
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.then((snapshot) => {
onSnapshot(snapshot);
return snapshot;
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
revisions = { ...revisions, ...(snapshot.revisions || {}) };
onSnapshot(retainedSnapshot, changedSections);
return retainedSnapshot;
})
.catch((error) => {
onError(error);
if (activeRequest === requestState && !stopped) {
const retryAfterMs = Number(error && error.retryAfterMs);
failureStreak += 1;
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
? retryAfterMs
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
onError(error);
}
return null;
})
.finally(() => {
inFlight = null;
schedule();
requestState.settled = true;
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule(nextDelayMs);
});
return inFlight;
return requestState.promise;
}
function setVisible(visible) {
cancelTimer();
if (!visible) return Promise.resolve(null);
return refresh();
return refresh({ force: true });
}
return {
@@ -64,10 +168,18 @@ function createContextPoller({
stop() {
stopped = true;
cancelTimer();
if (activeRequest) {
const request = activeRequest;
activeRequest = null;
supersede(request);
}
},
};
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
createContextPoller.retryAfterMs = retryAfterMs;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
}
+36
View File
@@ -0,0 +1,36 @@
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
const completed = new Set();
function available() {
return todayWork.read().length < todayWork.limit;
}
function complete(issue) {
const identity = todayWork.identity(issue);
if (!identity || completed.has(identity)) return 'exists';
const added = todayWork.add(issue);
if (added === 'full') {
announce('Created, but Today changed—remove an item, then add this issue.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
announce('Created, but Today could not be saved on this device.');
return 'unavailable';
}
if (!todaySync.enqueue('add', identity)) {
announce('Created and saved to Today on this device, but account sync is unavailable.');
return 'sync-unavailable';
}
completed.add(identity);
refresh();
todaySync.flush();
warm();
start(issue);
announce('Created, added to Today, and ready to work.');
return 'started';
}
return { available, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
+87 -1
View File
@@ -23,6 +23,11 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
let pending = null;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@@ -140,6 +145,85 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
.then(payload => ({
items: Array.isArray(payload?.items) ? payload.items : [],
page: Number(payload?.page) || safePage,
total: Math.max(0, Number(payload?.total) || 0),
has_more: payload?.has_more === true,
}))
.finally(() => repositoryPageRequests.delete(safePage));
repositoryPageRequests.set(safePage, request);
return request;
}
async function searchRepositories(value) {
const query = String(value || '').trim().slice(0, 80);
const request = ++repositorySearchRequest;
if (query.length < 2) return {status: 'idle', items: []};
try {
const payload = await fetchJson(
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
);
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
return {
status: 'ready',
items: Array.isArray(payload?.items) ? payload.items : [],
};
} catch (error) {
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
return {status: 'failed', items: [], error};
}
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
return { repository, title, key: repository + '\n' + title.toLowerCase() };
}
async function findDuplicates(draft) {
const input = duplicateKey(draft);
const request = ++duplicateRequest;
if (!input.repository || input.title.length < 4) {
duplicateState = {status: 'idle', key: input.key, candidates: []};
return duplicateState;
}
try {
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
duplicateState = {
status: 'ready',
key: input.key,
partial: payload?.partial === true,
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
).slice(0, 3),
};
return duplicateState;
} catch (error) {
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
return duplicateState;
}
}
function needsDuplicateAcknowledgement(draft) {
const {key} = duplicateKey(draft);
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
}
function acknowledgeDuplicates(draft) {
const {key} = duplicateKey(draft);
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
acknowledgedDuplicateKey = key;
}
}
function submit(draft) {
if (pending) return pending;
const saved = saveDraft(draft);
@@ -166,7 +250,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
searchRepositories, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
+513
View File
@@ -0,0 +1,513 @@
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.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; }
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
#sign-out-all { min-height:44px; }
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.active-devices-sheet[hidden] { display:none; }
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; }
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
.authentication-alert { border-color:#d69e2e; background:#241b09; }
.authentication-alert strong { color:#f6c453; }
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
#load-more-security-activity { width:100%; min-height:44px; }
button:hover { filter: brightness(1.15); }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
.panel > summary h2 { display: inline-block; margin-left: 4px; }
.panel[open] > summary { margin-bottom: 8px; }
aside, section { position: relative; z-index: 1; }
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
.sidebar { display:flex; flex-direction:column; gap: 12px; }
.stack { display:flex; flex-direction:column; gap:10px; }
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #94a3b8; }
.kv .value { color: #e5e7eb; }
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; }
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
.small { font-size: 12px; color: #94a3b8; }
.status { display:inline-flex; gap:6px; align-items:center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
.offline-status[hidden] { display:none; }
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; }
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.offline-today-readiness[hidden] { display:none; }
.offline-today-readiness button { min-height:44px; }
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.install-app-card[hidden] { display:none; }
.install-app-card p { margin:0; flex:1 1 240px; }
.install-app-card button { min-height:44px; }
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.plan-today-sheet[hidden] { display:none; }
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
.plan-today-available span { display:flex; align-items:center; gap:6px; }
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
.plan-preview-actions[hidden] { display:none; }
.plan-preview-actions button { min-height:44px; }
.plan-today-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; }
.plan-today-actions button { min-height:44px; width:100%; }
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; 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; }
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-readiness-header h2 { margin:.2rem 0; }
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.today-readiness-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; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-settings { width:100%; }
.work-settings > summary { display:none; }
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
.work-filter { min-height: 44px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.delivery-center h3, .delivery-center p { margin:0; }
.delivery-center button { min-height:44px; flex:0 0 auto; }
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.update-selection-controls button { min-height:44px; }
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
.my-work-card.selection-active { border-color:#31577f; }
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
.card-planning { min-width:0; }
.card-planning > summary { display:none; }
.card-planning:not([open]) > .card-planning-actions { display:grid; }
.card-planning-actions { display:grid; gap:8px; min-width:0; }
.detail-defer { grid-column:1/-1; max-width:100%; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.later-picker[hidden] { display:none; }
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.later-picker-header h2 { margin:0; }
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
.later-picker-error { min-height:1.4em; color:#fca5a5; }
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[hidden] { display:none; }
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
.load-more-work[hidden] { display:none; }
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
.retry-work-route[hidden] { display:none; }
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-bulk button { min-height:44px; width:100%; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
.review-file-toggle strong { overflow-wrap:anywhere; }
.review-file.reviewed { opacity:.72; }
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
.review-mark { min-height:44px; width:100%; margin-top:8px; }
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
.review-note { min-height:88px; margin-top:6px; }
.review-feedback { display:grid; gap:8px; }
.review-feedback label { display:grid; gap:6px; }
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
.review-handoff-link[hidden] { display:none; }
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
#continue-review-to-merge { min-height:44px; width:100%; }
#continue-review-to-merge[hidden] { display:none; }
.review-copy-fallback { min-height:140px; }
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
.review-progress-actions button { min-height:44px; max-width:100%; }
.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.review-display-tools button { min-height:44px; flex:0 0 auto; }
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
.review-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
.review-inline-composer[hidden] { display:none; }
.review-inline-composer textarea { min-height:96px; width:100%; }
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.review-inline-actions button { min-height:44px; }
.review-diff-line.hunk { color:#93c5fd; }
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
.review-action { min-height:44px; }
.review-retry { min-height:44px; margin-top:10px; }
.ci-checks { max-width:100%; margin:10px 0; border:1px solid #2a496e; border-radius:10px; overflow:hidden; }
.ci-checks > summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 10px; cursor:pointer; }
.ci-checks > summary .small { min-width:0; overflow-wrap:anywhere; text-align:right; }
.ci-check-refresh { width:100%; min-height:44px; margin-bottom:6px; }
.ci-check-list { display:grid; gap:6px; min-width:0; padding:0 8px 8px; }
.ci-check { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:8px; border:1px solid #203a5c; border-radius:8px; }
.ci-check-failure, .ci-check-error { border-color:#dc2626; background:rgba(127,29,29,.22); }
.ci-check-pending { border-color:#d97706; background:rgba(120,53,15,.2); }
.ci-check-success { border-color:#15803d; }
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions button { min-height:44px; width:100%; }
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
@media (max-width:390px) {
.update-reply-actions { grid-template-columns:1fr; }
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
}
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-ownership-actions button { min-width:0; width:100%; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.issue-sheet.open { display:flex; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
.issue-blocker-row .issue-blocker { min-width:0; }
.issue-blocker-remove { min-height:44px; white-space:normal; }
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
#edit-issue-content { min-height:44px; width:100%; }
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
.issue-edit-form textarea { min-height:132px; resize:vertical; }
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
.mention-options[hidden] { display:none; }
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
.mention-option:last-child { border-bottom:0; }
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
.mention-status:empty { display:none; }
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.comment-actions button { min-height:44px; width:100%; }
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.search-preview.open { display:flex; }
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
.markdown-content > :last-child { margin-bottom:0; }
.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; }
.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; }
.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; }
.markdown-content ul { padding-left:24px; }
.markdown-content .task-list { padding-left:0; list-style:none; }
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
#create-issue-repository-results[hidden] { display:none; }
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
.create-issue-repository-result:last-child { border-bottom:0; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.create-issue-label-option input { width:20px; height:20px; margin:0; }
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
.create-issue-duplicates[hidden] { display:none; }
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
.create-issue-duplicate-card a { color:#93c5fd; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.shared-content-actions button { min-height:44px; }
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.pull-sheet.open { display:flex; }
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
.pull-diff-line { display:block; width:max-content; min-width:100%; }
.pull-diff-line.added { color:#86efac; background:#123422; }
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
.pull-review-tools button { min-height:44px; }
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review summary h2 { margin:0; font-size:16px; }
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
.mobile-task-dock { display:none; }
.mobile-task-dock[hidden] { display:none; }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
.app-menu { position:relative; margin-left:0; }
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
.app-menu > summary::-webkit-details-marker { display:none; }
.app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; }
.my-work { margin:0; }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
.card-planning > summary::-webkit-details-marker { display:none; }
.card-planning:not([open]) > .card-planning-actions { display:none; }
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
.plan-today-panel { width:100%; border-left:0; padding:14px; }
.plan-today-item { grid-template-columns:1fr; }
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-blocker-row { grid-template-columns:1fr; }
.issue-blocker-remove { width:100%; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
#cmd-palette.open { display:flex; flex-direction:column; }
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
#close-command-palette { min-height:44px; }
#cmd-input { flex:0 0 auto; min-height:44px; }
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
.create-issue-panel.composer-keyboard-active,
.issue-sheet-panel.composer-keyboard-active,
.pull-sheet-panel.composer-keyboard-active,
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
.composer-keyboard-active .create-issue-form,
.composer-keyboard-active .issue-comment-composer,
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
File diff suppressed because it is too large. Load diff
+19 -5
View File
@@ -1,6 +1,7 @@
function createDetailDefer({
laterWork,
session,
continueSession = null,
close,
refresh,
focus,
@@ -8,17 +9,30 @@ function createDetailDefer({
formatTime = value => new Date(value).toLocaleString(),
}) {
return {
defer(item, preset) {
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
if (!item) return false;
const until = laterWork.presetUntil(preset);
if (!laterWork.defer(item, until)) return false;
const result = laterWork.defer(item, until);
if (result !== 'deferred') {
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
return false;
}
const inSession = session.active();
if (!inSession) close();
if (continueSession && session.checkpointed?.(item)) {
if (continueSession(item) === true) return true;
laterWork.restore(item);
refresh();
announce('Could not remove this item from Today, so it was restored from Later.');
return false;
}
if (!inSession && closeSheet) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
if (!inSession) focus();
if (!inSession && restoreFocus) focus();
return true;
},
defer(item, preset) {
return this.deferUntil(item, laterWork.presetUntil(preset));
},
};
}
+50 -9
View File
@@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
function parseOutbox(raw) {
try {
const record = JSON.parse(raw);
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
@@ -112,8 +112,13 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
id: 'stackchain.issue-outbox.v1:' + item.id,
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
status: item.status === 'completion' ? 'completion' :
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'completion' ? 'Created · ready to start' :
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
delivery_state: item.deliveryState,
repository: item.repository,
title: textPreview(item.title) || 'Untitled queued issue',
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
@@ -121,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
last_attempt_at: Number(item.lastAttemptAt || 0),
last_attempt_error: textPreview(item.lastAttemptError),
updated_at: Number(item.queuedAt || 0),
};
});
@@ -134,24 +141,43 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
const currentLogin = String(getCurrentLogin() || '').trim();
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
const isReview = item.kind === 'pull-review';
const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
String(comment.path || 'File') + ' · ' +
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
': ' + String(comment.body || '')
).filter(Boolean) : [];
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
authorization_required: isClosure || (isReview && item.status === 'authorization'),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
copy_text: copyText,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
last_attempt_at: Number(item.lastAttemptAt || 0),
last_attempt_error: textPreview(item.lastAttemptError),
updated_at: Number(item.queuedAt || 0),
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
{ kind:routeKind, repository:item.repository, number:item.number },
@@ -210,7 +236,22 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
} catch (_error) { return false; }
}
return { list, discard };
function partition(items = list()) {
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
const counts = deliveries.reduce((summary, item) => {
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
(item.status === 'authorization' ? 'authorization' : 'waiting'));
summary[state] += 1;
return summary;
}, { waiting:0, sending:0, attention:0, authorization:0 });
const retryable = deliveries.filter(item =>
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
);
return { drafts, deliveries, counts, retryable };
}
return { list, discard, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
+62
View File
@@ -0,0 +1,62 @@
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
const pending = new Map();
const loaded = new Set();
function load(name) {
if (loaded.has(name)) return Promise.resolve();
if (pending.has(name)) return pending.get(name);
const url = urls[name];
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
const request = new Promise((resolve, reject) => {
const script = document.createElement('script');
let timeout;
const fail = () => {
clearTimeout(timeout);
script.remove();
pending.delete(name);
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
};
script.src = url;
script.async = true;
script.onload = () => {
clearTimeout(timeout);
pending.delete(name);
loaded.add(name);
resolve();
};
script.onerror = fail;
timeout = setTimeout(fail, timeoutMs);
document.head.appendChild(script);
});
pending.set(name, request);
return request;
}
async function run(name, elements, callback) {
const trigger = elements?.trigger;
const status = elements?.status;
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
if (trigger) trigger.disabled = true;
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
try {
await load(name);
callback();
if (status) status.textContent = '';
return true;
} catch (_error) {
if (status) {
const label = name === 'issue-capture' ? 'Issue capture' :
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
status.textContent = label + ' could not load. ' + retryLabel;
}
return false;
} finally {
if (trigger) trigger.disabled = false;
}
}
return { load, run, ready: name => loaded.has(name) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
+331 -3137
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createInstallApp = factory;
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
const dismissedKey = 'stackchain.install.dismissed.v1';
let deferredPrompt = null;
function hide() {
options.card.hidden = true;
}
function showNativePrompt(event) {
event.preventDefault();
deferredPrompt = event;
options.guidance.hidden = true;
options.installButton.hidden = false;
options.card.hidden = false;
}
async function install() {
if (!deferredPrompt) return;
const prompt = deferredPrompt;
deferredPrompt = null;
options.installButton.disabled = true;
await prompt.prompt();
const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') {
hide();
options.status.textContent = 'Stackchain was added to your device.';
} else {
hide();
options.installButton.disabled = false;
options.status.textContent = 'Installation was not completed.';
}
}
function dismiss() {
options.storage.setItem(dismissedKey, '1');
hide();
}
function start() {
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
hide();
return;
}
if (options.isIosSafari()) {
options.installButton.hidden = true;
options.guidance.hidden = false;
options.card.hidden = false;
}
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
options.window.addEventListener('appinstalled', () => {
deferredPrompt = null;
hide();
});
options.installButton.addEventListener('click', install);
options.dismissButton.addEventListener('click', dismiss);
}
return {start};
});
+259
View File
@@ -0,0 +1,259 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function namedBlob(blob, name) {
if (typeof File === 'function') {
return new File([blob], name, { type: blob.type });
}
Object.defineProperty(blob, 'name', { value: name, configurable: true });
return blob;
}
async function optimizeImage(file, environment = {}) {
if (file.size <= MAX_BYTES) return file;
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
if (typeof decode !== 'function') {
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
}
let bitmap;
try {
bitmap = await decode(file);
const canvas = makeCanvas();
const context = canvas && canvas.getContext && canvas.getContext('2d');
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
for (let attempt = 0; attempt < 10; attempt += 1) {
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise(resolve => {
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
});
if (!blob) throw new Error('encode');
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
scale *= 0.8;
}
} catch (_error) {
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
} finally {
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
}
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
function multipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
function create(options) {
const upload = options.upload;
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
const createOperationId = options.createOperationId || (() => {
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
});
let selected = null;
let confirmed = null;
let serialized = null;
let operationId = null;
let selectionGeneration = 0;
function commitSelection(file) {
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
}
selected = file;
confirmed = null;
serialized = null;
operationId = createOperationId();
return state();
}
function select(file) {
const generation = ++selectionGeneration;
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
}
if (!Number.isFinite(file.size) || file.size <= 0) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
if (file.size > MAX_BYTES) {
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
if (generation !== selectionGeneration) return state();
return commitSelection(optimized);
});
}
return commitSelection(file);
}
function clear() {
selectionGeneration += 1;
selected = null;
confirmed = null;
serialized = null;
operationId = null;
}
function restore(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const blob = value?.blob;
const data = String(value?.data || '');
if (!blob && !data) {
clear();
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const padding = (data.match(/=*$/) || [''])[0].length;
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
return state();
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
serialized = {
filename: selected.name,
contentType: selected.type,
blob: selected.blob || selected,
};
}
return { ...serialized };
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
operation_id: operationId,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, restore, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
let selectionSequence = 0;
function setBusy(busy) {
options.input.disabled = Boolean(busy);
options.remove.disabled = Boolean(busy);
}
function clearPreview() {
selectionSequence += 1;
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
options.input.disabled = false;
clearSelection();
}
function showPreview(file, optimized) {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
(options.readyMessage || 'Screenshot ready to upload with this comment.');
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
const sequence = ++selectionSequence;
let result;
try {
result = controller.select(file);
} catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (!result || typeof result.then !== 'function') {
showPreview(file, false);
return;
}
options.status.textContent = 'Optimizing screenshot…';
options.input.disabled = true;
return result.then(() => controller.serialize()).then(value => {
if (sequence === selectionSequence) showPreview(value.blob, true);
}).catch(error => {
if (sequence === selectionSequence) {
options.status.textContent = error.message;
options.input.value = '';
}
}).finally(() => {
if (sequence === selectionSequence) options.input.disabled = false;
});
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = value.blob ? options.createObjectURL(value.blob) :
'data:' + value.contentType + ';base64,' + value.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
return restored;
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
}
return { create, mount, multipart, optimizeImage, MAX_BYTES };
});
+269 -41
View File
@@ -5,16 +5,28 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
);
const pending = new Map();
function captureAttachment(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '').slice(0, 255);
const blob = value?.blob;
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
if (blob) return { filename, contentType, blob };
if (!data && value?.stored === true) return { filename, contentType, stored: true };
if (!data) return undefined;
return { filename, contentType, data };
}
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item === 'object');
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
coordinator?.notify('issue');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items))
@@ -23,7 +35,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
}
function enqueue(draft, mirror = true) {
function prepareItem(draft) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
@@ -39,58 +51,134 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
}
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
return item;
}
function localIndexItem(item) {
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
return {
...item,
attachment: {
filename: item.attachment.filename,
contentType: item.attachment.contentType,
stored: true,
},
};
}
function enqueue(draft, mirror = true) {
const items = read();
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
return item;
}
async function enqueueDurably(draft) {
const item = enqueue(draft, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = enqueue(draft, false);
return { item, background: false, durability: 'foreground-only' };
}
const item = prepareItem(draft);
const current = read();
try {
await backgroundSync.reconcile(read());
await backgroundSync.reconcile([...current, item]);
const localItem = localIndexItem(item);
write([...current, item].map(localIndexItem), false);
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
return { item: localItem, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
const persisted = read().find(candidate => candidate.id === item.id);
if (!persisted) throw error;
return { item: persisted, background: false, durability: 'foreground-only', error };
}
}
function update(id, draft, mirror = true) {
async function hydrateForEdit(id) {
const item = read().find(candidate => candidate.id === id);
if (!item) return null;
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
if (!backgroundSync?.get) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
const durable = await backgroundSync.get(id);
const attachment = captureAttachment(durable?.attachment);
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
}
return { ...item, attachment };
}
function prepareUpdate(id, draft) {
let updated = null;
write(read().map(item => {
const items = read().map(item => {
if (item.id !== id) return item;
const nextRepository = String(draft?.repository || '');
const nextTitle = String(draft?.title || '');
const nextBody = String(draft?.body || '');
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
updated = {
...item,
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
else delete updated.completionIntent;
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
delete updated.error;
delete updated.deliveryState;
return updated;
}), mirror);
});
return { items, updated };
}
function update(id, draft, mirror = true) {
const { items, updated } = prepareUpdate(id, draft);
write(items, mirror);
return updated;
}
async function updateDurably(id, draft) {
const item = update(id, draft, false);
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = update(id, draft, false);
return { item, background: false, durability: 'foreground-only' };
}
const { items, updated: item } = prepareUpdate(id, draft);
if (!item) return { item, background: false, durability: 'foreground-only' };
try {
await backgroundSync.reconcile(read());
await backgroundSync.reconcile(items);
const localItems = items.map(localIndexItem);
write(localItems, false);
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
return { item: localIndexItem(item), background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
const persisted = read().find(candidate => candidate.id === id);
if (!persisted || persisted.operationId !== item.operationId) throw error;
return { item: persisted, background: false, durability: 'foreground-only', error };
}
}
@@ -101,9 +189,88 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return true;
}
function persistDeliveryStage(id, stage) {
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
function attachmentMultipart(attachment) {
let blob = attachment?.blob;
if (!blob && attachment?.data) {
const binary = atob(String(attachment.data));
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
}
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
const form = new FormData();
form.append('file', blob, String(attachment.filename || 'screenshot'));
return form;
}
async function sendDirect(item, repository) {
let issue = item.deliveredIssue;
if (!issue) {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: attachmentMultipart(item.attachment),
},
);
markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
}
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: markdown }),
},
);
return issue;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
const attemptAt = Number(now());
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'sending', lastAttemptAt:attemptAt,
} : candidate), false);
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
const request = (async () => {
try {
@@ -117,27 +284,23 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
issue = delivery.issue;
} else {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
issue = await sendDirect(item, repository);
}
if (!issue) return { blocked: true };
discard(item.id);
return { issue };
return { issue, item };
} catch (error) {
const status = Number(error?.status || 0);
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
if (status >= 400 && status < 500) {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
} else {
write(read().map(candidate => candidate.id === item.id ? {
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
@@ -150,16 +313,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
async function flushQueue(currentLogin) {
const confirmed = [];
const completions = [];
let blocked = 0;
currentLogin = String(currentLogin || '').trim();
for (const item of read()) {
if (item.status === 'attention') continue;
if (item.status === 'attention' || item.status === 'completion') continue;
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
const result = await sendItem(item, currentLogin);
if (result.issue) confirmed.push(result.issue);
if (result.issue) {
confirmed.push(result.issue);
if (result.item?.completionIntent) completions.push({
id: result.item.id,
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
issue: result.issue,
});
}
if (result.transient) break;
}
return { confirmed, remaining: read(), blocked };
return { confirmed, completions, remaining: read(), blocked };
}
async function flush(currentLogin) {
@@ -174,9 +347,29 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (!currentLogin || item.ownerLogin !== currentLogin) {
return { confirmed: [], remaining: read(), blocked: 1 };
}
update(id, item);
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
let queued = item;
if (item.status === 'attention') {
queued = {
...item,
operationId: String(operationId()).slice(0, 128),
status: 'queued',
};
delete queued.error;
delete queued.deliveryState;
write(read().map(candidate => candidate.id === id ? queued : candidate));
}
const result = await sendItem(queued, currentLogin);
return {
confirmed: result.issue ? [result.issue] : [],
completions: result.issue && result.item?.completionIntent ? [{
id: result.item.id,
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
issue: result.issue,
}] : [],
remaining: read(), blocked: 0,
};
}
async function retry(id, currentLogin) {
@@ -188,17 +381,52 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
const deliveryStage = {
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
};
if (background?.status === 'sent') {
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
}];
return [];
}
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
return [{ ...localIndexItem(item), ...deliveryStage }];
});
write(items);
return items;
}
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
function pendingCompletions(currentLogin) {
const login = String(currentLogin || '').trim();
if (!login) return [];
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
id: item.id,
intent: item.completionIntent,
ownerLogin: item.ownerLogin,
operationId: item.operationId,
issue: item.deliveredIssue,
}));
}
function completeIntent(id, currentLogin) {
const login = String(currentLogin || '').trim();
const items = read();
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
if (!matched) return false;
write(items.filter(item => item.id !== id));
return true;
}
return {
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+45
View File
@@ -22,10 +22,12 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let handoffRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
let milestoneRequest = null;
let blockerRequest = null;
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
'/issues/' + encodeURIComponent(item.number);
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
@@ -61,6 +63,29 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
loadHandoffCandidates(item) {
return fetchJson(issuePath(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
});
},
updateBlocker(item, blocker, remove = false) {
if (blockerRequest) return blockerRequest;
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
method: remove ? 'DELETE' : 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
}).then(result => {
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
const present = dependencies.some(candidate =>
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
);
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
throw new Error('Blocker change was not confirmed.');
}
return result;
}).finally(() => { blockerRequest = null; });
return blockerRequest;
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -209,6 +234,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
handoff(item, recipient, currentLogin) {
if (handoffRequest) return handoffRequest;
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient }),
}).then(result => {
if (
result?.number !== item.number ||
result?.recipient !== recipient ||
!Array.isArray(result.assignees) ||
!result.assignees.includes(recipient) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue handoff was not confirmed.');
}
return result;
}).finally(() => { handoffRequest = null; });
return handoffRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
+51
View File
@@ -0,0 +1,51 @@
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
const pending = new Map();
async function run(item, identity) {
const added = todayWork.add(item);
if (added === 'full') {
announce('Today is limited to 5 items. Remove one, then try Start now again.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
announce('Could not save Today on this device. The item remains in Later; try again.');
return added;
}
if (added === 'added' && !todaySync.enqueue('add', identity)) {
todayWork.remove(item);
announce('Today sync is unavailable. The item remains in Later; try again.');
return 'sync-unavailable';
}
if (!laterWork.restore(item)) {
if (added === 'added') {
todayWork.remove(item);
todaySync.enqueue('remove', identity);
todaySync.flush();
}
announce('Could not remove this item from Later. Nothing was started; try again.');
return 'later-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
const outcome = await start(item);
if (outcome === 'gated') {
announce('Moved to Today. Choose how to handle its blocker before starting.');
return 'gated';
}
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
return 'started';
}
function startDeferred(item) {
const identity = todayWork.identity(item);
if (pending.has(identity)) return pending.get(identity);
const operation = run(item, identity).finally(() => pending.delete(identity));
pending.set(identity, operation);
return operation;
}
return { start: startDeferred };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
+104
View File
@@ -0,0 +1,104 @@
function createLaterPicker({
now = () => new Date(),
history = null,
eventTarget = null,
onState = () => {},
onConfirm = () => false,
} = {}) {
let active = false;
let item = null;
let trigger = null;
let context = null;
let afterClose = null;
let committed = false;
let started = false;
function parse(input) {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(input || ''));
if (!match) return { ok:false, message:'Choose a valid local date and time.' };
const parts = match.slice(1).map(Number);
const value = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], 0, 0);
const sameLocalTime = value.getFullYear() === parts[0] &&
value.getMonth() === parts[1] - 1 && value.getDate() === parts[2] &&
value.getHours() === parts[3] && value.getMinutes() === parts[4];
if (!sameLocalTime) return { ok:false, message:'Choose a valid local date and time.' };
if (value <= now()) return { ok:false, message:'Choose a future date and time.' };
return { ok:true, value };
}
function formatLocal(value) {
const pad = part => String(part).padStart(2, '0');
return value.getFullYear() + '-' + pad(value.getMonth() + 1) + '-' + pad(value.getDate()) +
'T' + pad(value.getHours()) + ':' + pad(value.getMinutes());
}
function deactivate() {
if (!active) return false;
const restore = trigger;
active = false;
item = null;
trigger = null;
context = null;
committed = false;
onState({ open:false, message:'' });
restore?.focus?.();
const cleanup = afterClose;
afterClose = null;
cleanup?.();
return true;
}
function start() {
if (started || !eventTarget) return;
started = true;
eventTarget.addEventListener('popstate', event => {
if (active && !event.state?.laterPicker) deactivate();
});
eventTarget.addEventListener('keydown', event => {
if (active && event.key === 'Escape') {
event.preventDefault?.();
close();
}
});
}
function open(nextItem, nextTrigger, nextContext = 'card') {
if (!nextItem || active) return false;
active = true;
item = nextItem;
trigger = nextTrigger || null;
context = nextContext;
committed = false;
const suggested = new Date(now().getTime() + 60 * 60 * 1000);
suggested.setMinutes(Math.ceil(suggested.getMinutes() / 15) * 15, 0, 0);
if (history) history.pushState({ ...(history.state || {}), laterPicker:true }, '');
onState({ open:true, message:'', value:formatLocal(suggested) });
return true;
}
function close() {
if (!active) return false;
if (history?.state?.laterPicker) history.back();
else deactivate();
return true;
}
function submit(input) {
if (!active || committed) return false;
const result = parse(input);
if (!result.ok) {
onState({ open:true, message:result.message, value:String(input || '') });
return false;
}
const confirmed = onConfirm(item, result.value, context);
if (confirmed !== true && typeof confirmed !== 'function') return false;
committed = true;
afterClose = typeof confirmed === 'function' ? confirmed : null;
close();
return true;
}
return { parse, start, open, close, submit, current:() => active };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterPicker;
+273
View File
@@ -0,0 +1,273 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.later-sync.v1.';
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
let flushing = null;
let channel = null;
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
retryTimer = null;
}
function scheduleRetry(error, ownerKey) {
if (retryTimer !== null || !pending().length || !ownerKey) return;
const advised = Number(error?.retryAfter);
const delayMs = Number.isFinite(advised) && advised >= 0
? advised * 1000
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
retryAttempt += 1;
onStatus?.('retrying', { delayMs });
retryTimer = setTimer?.(async () => {
retryTimer = null;
if (key() !== ownerKey) return false;
return flush();
}, delayMs);
retryTimer?.unref?.();
}
function key() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function snapshotKey() {
const storageKey = key();
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
}
function savedRevision() {
try {
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
} catch (_error) {
return -1;
}
}
function validRecords(records) {
return records && typeof records === 'object' && !Array.isArray(records);
}
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
if (plan.revision < savedRevision()) return false;
const snapshot = { revision: plan.revision, records: plan.records };
try {
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
} catch (_error) {
// Server truth remains usable in this tab when storage is unavailable.
}
onRemoteRecords?.(plan.records);
if (broadcast) channel?.postMessage(snapshot);
return true;
}
function ensureChannel() {
const storageKey = key();
if (!storageKey || channelKey === storageKey) return;
channel?.close?.();
const factory = createChannel || (globalThis.window?.BroadcastChannel
? name => new globalThis.window.BroadcastChannel(name)
: null);
channelKey = storageKey;
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
channel?.addEventListener?.('message', event => {
if (key() === storageKey) adopt(event.data, false);
});
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation);
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
now() - Number(record.queued_at) > maxOfflineMs);
expired.forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
if (expired.length) {
expiredCount += expired.length;
onStatus?.('expired', { count: expiredCount });
}
const activeRecords = records.filter(record => !expired.includes(record))
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id));
const latestByItem = new Map();
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => ({
...record.operation,
base_revision: Number.isInteger(record.operation.base_revision)
? record.operation.base_revision : 0,
})).filter(operation =>
operation && typeof operation.operation_id === 'string' &&
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
} catch (_error) {
return [];
}
}
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
}
}
function operationId() {
if (createOperationId) return createOperationId();
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, wakeAt = null) {
if (!['defer', 'restore'].includes(action) || !itemId ||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
pending().filter(operation => operation.item_id === itemId)
.forEach(operation => removeOperation(operation.operation_id));
const operation = {
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('later');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function migrate(records) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
enqueue('defer', itemId, wakeAt)
);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
expiredCount = 0;
try {
let operations = pending();
let plan;
let conflictCount = 0;
if (!operations.length) plan = await fetchJson('api/v1/later');
while (operations.length) {
if (key() !== ownerKey) return false;
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/later', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: batch }),
});
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
const received = hasReceipts ? [
...(plan.accepted_operation_ids || []),
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
conflictCount += (plan.rejected_operations || [])
.filter(item => item.reason === 'stale_intent').length;
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
throw new Error('Could not persist Later delivery receipt');
}
}
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' :
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
retryAttempt = 0;
cancelRetry();
return true;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
return false;
}
}
function flush() {
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
}
coordinator?.subscribe(change => {
if (change.queue === 'later' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
+29 -9
View File
@@ -1,4 +1,4 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
@@ -42,11 +42,14 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
records[id] = wake.toISOString();
write(records);
return true;
const wakeAt = wake.toISOString();
records[id] = wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
return 'deferred';
}
function presetUntil(preset) {
@@ -66,10 +69,21 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
write(records);
if (!write(records)) return false;
onChange('restore', id, null);
return true;
}
function adopt(records) {
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
const normalized = {};
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
});
return write(normalized);
}
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
@@ -87,16 +101,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
if (Number.isFinite(wakeTime)) expired.push(id);
return;
}
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
onExpire(expired);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
@@ -107,7 +127,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
return { identity, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+129 -6
View File
@@ -2,15 +2,86 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createLoginController = factory;
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
const invalid = entries.some(([name, content]) => (
!Object.prototype.hasOwnProperty.call(limits, name)
|| !content
|| content.length > limits[name]
));
if (invalid) return './';
return value;
}
const form = options.form;
const status = options.status;
const button = options.button;
const passkeyButton = options.passkeyButton;
const credentials = options.credentials;
const fetchImpl = options.fetchImpl;
const location = options.location;
const clearPrivateDeviceData = options.clearPrivateDeviceData;
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null;
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
const bytes = new Uint8Array(value);
let binary = '';
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function credentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
async function showReason(reason) {
if (reason === 'session-expired') {
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
try {
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
await clearPrivateDeviceData();
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
button.disabled = false;
} catch (_error) {
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.';
}
}
function showRetryCountdown(seconds) {
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
button.disabled = true;
@@ -28,14 +99,14 @@
}, 1000);
}
async function submit(accessToken) {
async function submit(accessToken, deviceLabel = 'This device') {
status.textContent = 'Signing in…';
let response;
try {
response = await fetchImpl('api/v1/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: accessToken }),
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
});
} catch (_error) {
form.reset();
@@ -44,7 +115,7 @@
}
form.reset();
if (response.ok) {
location.replace('./');
location.replace(continuation);
return;
}
if (response.status === 429) {
@@ -54,23 +125,75 @@
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit };
async function signInWithPasskey(deviceLabel = 'This device') {
if (!credentials?.get) {
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
return false;
}
status.textContent = 'Waiting for your passkey…';
try {
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: credentialJSON(credential),
device_label: deviceLabel,
action: 'sign_in',
target: 'dashboard',
}),
});
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} catch (_error) {
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
return false;
}
}
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
return { submit, signInWithPasskey, showReason };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const passkeyButton = document.getElementById('passkey-sign-in');
const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({
form,
status,
button,
passkeyButton,
credentials: window.navigator?.credentials,
fetchImpl: fetch.bind(window),
location: window.location,
continuation: loginParams.get('continue'),
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
});
controller.showReason(loginParams.get('reason'));
form.addEventListener('submit', event => {
event.preventDefault();
const accessToken = new FormData(form).get('access_token');
controller.submit(accessToken);
const data = new FormData(form);
const accessToken = data.get('access_token');
const deviceLabel = data.get('device_label');
controller.submit(accessToken, deviceLabel);
});
passkeyButton?.addEventListener('click', () => {
const data = new FormData(form);
controller.signInWithPasskey(data.get('device_label'));
});
}
+88 -10
View File
@@ -13,17 +13,95 @@
})[character]);
}
return function renderMarkdown(raw) {
return escapeHtml(raw)
.replace(/^#{1,6}\s.*$/gm, (line) => {
const level = line.match(/^(#{1,6})/)[1].length;
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
function renderInline(value) {
const code = [];
let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => {
const marker = '\u0000CODE' + code.length + '\u0000';
code.push('<code>' + body + '</code>');
return marker;
});
rendered = rendered
.replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+&quot;[^&]*?&quot;)?\)/g, (_match, label, escapedUrl) => {
const url = escapedUrl.replace(/&amp;/g, '&');
const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url);
if (!safe) return label;
return '<a href="' + escapedUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
})
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br>');
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
}
function renderList(lines) {
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
const items = lines.map(line => {
let body = line.replace(/^[-*+]\s+/, '');
if (!taskList) return '<li>' + renderInline(body) + '</li>';
const checked = /^\[[xX]\]\s+/.test(body);
body = body.replace(/^\[[ xX]\]\s+/, '');
return '<li class="task-list-item"><input type="checkbox" disabled' +
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
}).join('');
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
}
return function renderMarkdown(raw) {
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
const output = [];
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
const body = [];
index += 1;
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
body.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
const language = fence[1] ? ' class="language-' + fence[1] + '"' : '';
output.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
output.push('<h' + level + '>' + renderInline(heading[2]) + '</h' + level + '>');
index += 1;
continue;
}
if (/^>\s?/.test(line)) {
const quote = [];
while (index < lines.length && /^>\s?/.test(lines[index])) {
quote.push(lines[index].replace(/^>\s?/, ''));
index += 1;
}
output.push('<blockquote><p>' + renderInline(quote.join('\n')).replace(/\n/g, '<br>') + '</p></blockquote>');
continue;
}
if (/^[-*+]\s+/.test(line)) {
const list = [];
while (index < lines.length && /^[-*+]\s+/.test(lines[index])) {
list.push(lines[index]);
index += 1;
}
output.push(renderList(list));
continue;
}
const paragraph = [];
while (index < lines.length && lines[index].trim() &&
!/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) &&
!/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) {
paragraph.push(lines[index]);
index += 1;
}
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
}
return output.join('');
};
});
+147
View File
@@ -0,0 +1,147 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMentionComposer = api.create;
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
'use strict';
function activeMention(value, caret) {
const before = String(value || '').slice(0, Math.max(0, caret));
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
if (!match) return null;
const start = before.length - match[2].length - 1;
return { start, end:before.length, query:match[2] };
}
function insertMention(value, mention, login) {
const text = String(value || '');
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
const inserted = '@' + login + ' ';
return {
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
caret:mention.start + inserted.length,
};
}
function create(options) {
const textarea = options.textarea;
const listbox = options.listbox;
const status = options.status;
const setTimer = options.setTimer || setTimeout;
const clearTimer = options.clearTimer || clearTimeout;
const createOption = options.createOption || (() => document.createElement('button'));
let timer = null;
let requestId = 0;
let mention = null;
let candidates = [];
let activeIndex = -1;
function dismiss() {
requestId += 1;
candidates = [];
activeIndex = -1;
listbox.replaceChildren();
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
textarea.removeAttribute('aria-activedescendant');
status.textContent = '';
}
function activate(index) {
if (!candidates.length) return;
activeIndex = (index + candidates.length) % candidates.length;
listbox.children.forEach((option, optionIndex) => {
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
});
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
}
function select(index) {
if (!mention || !candidates[index]) return;
const result = insertMention(textarea.value, mention, candidates[index].login);
textarea.value = result.value;
textarea.setSelectionRange(result.caret, result.caret);
dismiss();
textarea.focus();
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
}
function render(items) {
candidates = Array.isArray(items) ? items : [];
activeIndex = -1;
listbox.replaceChildren();
candidates.forEach((candidate, index) => {
const option = createOption();
option.type = 'button';
option.id = listbox.id + '-option-' + index;
option.className = 'mention-option';
option.dataset.login = candidate.login;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
option.addEventListener('pointerdown', event => {
event.preventDefault();
select(index);
});
listbox.appendChild(option);
});
listbox.hidden = !candidates.length;
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
}
function schedule() {
if (timer !== null) clearTimer(timer);
mention = activeMention(textarea.value, textarea.selectionStart);
const repository = options.getRepository();
if (!mention || !repository) {
dismiss();
return;
}
const currentRequest = ++requestId;
timer = setTimer(async () => {
try {
const items = await options.loadCandidates(repository, mention.query);
if (currentRequest === requestId && repository === options.getRepository()) render(items);
} catch (_error) {
if (currentRequest === requestId) {
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
}
}
}, options.delayMs ?? 180);
}
function keydown(event) {
if (event.key === 'Escape' && !listbox.hidden) {
event.preventDefault();
dismiss();
} else if (event.key === 'ArrowDown' && candidates.length) {
event.preventDefault();
activate(activeIndex + 1);
} else if (event.key === 'ArrowUp' && candidates.length) {
event.preventDefault();
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
select(activeIndex);
}
}
return {
start() {
textarea.setAttribute('autocomplete', 'off');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', listbox.id);
textarea.setAttribute('aria-expanded', 'false');
textarea.addEventListener('input', schedule);
textarea.addEventListener('click', schedule);
textarea.addEventListener('keydown', keydown);
},
dismiss,
};
}
return { activeMention, insertMention, create };
});
+96
View File
@@ -0,0 +1,96 @@
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) module.exports = exported;
else root.createMobileComposerViewport = exported;
})(typeof self !== 'undefined' ? self : this, function () {
return function createMobileComposerViewport(options) {
const entries = options.entries || [];
const viewport = options.viewport;
const mediaQuery = options.mediaQuery;
const documentRef = options.document || document;
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
let active = null;
let started = false;
function reveal() {
if (!active) return;
const focused = documentRef.activeElement;
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
}
function applyGeometry() {
if (!active || !viewport || !mediaQuery.matches) return;
active.panel.style.setProperty('--composer-viewport-top', `${viewport.offsetTop || 0}px`);
active.panel.style.setProperty('--composer-viewport-height', `${viewport.height}px`);
reveal();
}
function onViewportChange() {
schedule(applyGeometry);
}
function activate(entry) {
if (!viewport || !mediaQuery.matches) return;
if (active === entry) {
schedule(applyGeometry);
return;
}
if (active) deactivate(active, false);
active = entry;
entry.scrollTop = entry.panel.scrollTop;
entry.panel.classList?.add('composer-keyboard-active');
viewport.addEventListener('resize', onViewportChange);
viewport.addEventListener('scroll', onViewportChange);
applyGeometry();
}
function deactivate(entry, restore = true) {
if (active !== entry) return;
viewport?.removeEventListener('resize', onViewportChange);
viewport?.removeEventListener('scroll', onViewportChange);
entry.panel.classList?.remove('composer-keyboard-active');
entry.panel.style.removeProperty('--composer-viewport-top');
entry.panel.style.removeProperty('--composer-viewport-height');
active = null;
if (restore) schedule(() => { entry.panel.scrollTop = entry.scrollTop; });
}
function bind(entry) {
entry.onFocus = () => activate(entry);
entry.onFocusOut = () => schedule(() => {
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
});
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
entry.workspace.addEventListener('focusout', entry.onFocusOut);
}
function unbind(entry) {
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
}
return {
start() {
if (started) return;
started = true;
entries.forEach(bind);
},
close(panel) {
const entry = entries.find(candidate => candidate.panel === panel);
if (entry) deactivate(entry, false);
},
stop() {
if (!started) return;
if (active) deactivate(active, false);
entries.forEach(unbind);
started = false;
},
revealActive() {
schedule(applyGeometry);
},
};
};
});
+32
View File
@@ -0,0 +1,32 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.mobileLaunch = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const allowedFilters = new Set([
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
]);
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
if (allowedFilters.has(saved)) return saved;
if (today > 0) return 'today';
if (attention > 0) return 'attention';
return 'all';
}
function createDisclosure({ disclosure, launcher }) {
function onKeydown(event) {
if (event.key !== 'Escape' || !disclosure.open) return;
event.preventDefault();
disclosure.open = false;
launcher.focus();
}
return {
start() {
disclosure.addEventListener('keydown', onKeydown);
},
};
}
return { chooseFilter, createDisclosure };
});
+50
View File
@@ -0,0 +1,50 @@
(function (root, factory) {
const exported = factory();
if (typeof module === 'object' && module.exports) module.exports = exported;
else root.createMobileSearchViewport = exported;
})(typeof self !== 'undefined' ? self : this, function () {
return function createMobileSearchViewport(options) {
const palette = options.palette;
const results = options.results;
const viewport = options.viewport;
const mediaQuery = options.mediaQuery;
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
let active = false;
let scrollTop = 0;
function applyGeometry() {
if (!active || !mediaQuery.matches || !viewport) return;
palette.style.setProperty('--search-viewport-top', `${viewport.offsetTop || 0}px`);
palette.style.setProperty('--search-viewport-height', `${viewport.height}px`);
}
function onViewportChange() {
schedule(applyGeometry);
}
return {
open() {
if (active || !mediaQuery.matches || !viewport) return;
active = true;
applyGeometry();
viewport.addEventListener('resize', onViewportChange);
viewport.addEventListener('scroll', onViewportChange);
},
close() {
if (active && viewport) {
viewport.removeEventListener('resize', onViewportChange);
viewport.removeEventListener('scroll', onViewportChange);
}
active = false;
palette.style.removeProperty('--search-viewport-top');
palette.style.removeProperty('--search-viewport-height');
},
rememberScroll() {
scrollTop = results.scrollTop;
},
restoreScroll() {
schedule(() => { results.scrollTop = scrollTop; });
},
};
};
});
+16 -3
View File
@@ -7,6 +7,7 @@
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
let workMode = 'work';
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
@@ -34,9 +35,17 @@
refreshVisibility();
}
function updateAttention(count) {
function updateWork(mode, count) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
work: ['Work', 'Work'],
};
workMode = labels[mode] ? mode : 'work';
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
@@ -44,10 +53,14 @@
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
total ? 'Work, ' + total + ' items need attention' : 'Work'
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
);
}
}
return {start, refreshVisibility, updateAttention};
function updateAttention(count) {
updateWork(workMode, count);
}
return {start, refreshVisibility, updateAttention, updateWork};
});
+22
View File
@@ -0,0 +1,22 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileWorkEntry = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
return 'work';
}
function open() {
const current = mode();
if (current === 'continue') options.continueToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else options.openFallback();
return current;
}
return {mode, open};
});
+248 -27
View File
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
};
}
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
let active = false;
const selected = new Set();
const snapshot = () => ({
active,
ids: Array.from(selected),
count: selected.size,
limit: maximum,
at_limit: selected.size >= maximum,
});
const publish = () => onChange(snapshot());
const select = notificationId => {
if (!active || !Number.isInteger(notificationId)) return 'inactive';
if (selected.has(notificationId)) return 'already-selected';
if (selected.size >= maximum) return 'limit';
selected.add(notificationId);
publish();
return 'selected';
};
return {
start() {
if (active) return snapshot();
active = true;
publish();
return snapshot();
},
select,
toggle(notificationId) {
if (!selected.has(notificationId)) return select(notificationId);
selected.delete(notificationId);
publish();
return 'deselected';
},
retain(notificationIds) {
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
let changed = false;
selected.forEach(id => {
if (!allowed.has(id)) {
selected.delete(id);
changed = true;
}
});
if (changed) publish();
return snapshot();
},
cancel() {
active = false;
selected.clear();
publish();
return snapshot();
},
snapshot,
};
}
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
@@ -280,6 +336,9 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
acknowledge = null,
queueRead = null,
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
@@ -288,14 +347,32 @@ function createNotificationReader({
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
async function open(item) {
async function advanceAfterRead(items, current, queueing = false) {
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item => item && item.has_update &&
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
if (next) await open(next, queueing ? loadSaved(next) : null);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
}
async function open(item, savedDetail = null) {
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
onOpen(item);
onStatus('Loading update…');
try {
const detail = await load(item.notification_id);
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -323,8 +400,12 @@ function createNotificationReader({
onConversation(conversationPager.append(comment));
return true;
},
acceptReadAndNext(items, item = selected) {
if (!item || selected !== item) return false;
return advanceAfterRead(items, item);
},
async loadOlder() {
if (!conversationPager || !selected) return false;
if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
@@ -342,28 +423,32 @@ function createNotificationReader({
}
},
async markReadAndNext(items) {
if (!selected || marking) return false;
if (!selected || marking || (offlineHydrated && !queueRead)) return false;
const current = selected;
const queueing = offlineHydrated;
marking = true;
onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
try {
if (queueing) await queueRead(current.notification_id);
else await markRead(current.notification_id);
return await advanceAfterRead(items, current, queueing);
} catch (_error) {
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false;
} finally {
marking = false;
}
},
async acknowledgeAndNext(items) {
if (!selected || marking || offlineHydrated || !acknowledge) return false;
const current = selected;
marking = true;
onStatus('Marking update read…');
onStatus('Adding reaction and marking read…');
try {
await markRead(current.notification_id);
const updated = acknowledgeNotification(items, current.notification_id);
onItems(updated);
const currentIndex = items.indexOf(current);
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
const next = remaining.find(item =>
item && item.has_update && Number.isInteger(item.notification_id)
);
if (next) await open(next);
else {
selected = null;
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
await acknowledge(current.notification_id);
return await advanceAfterRead(items, current);
} catch (_error) {
onStatus('Could not mark update read. Retry.');
onStatus('Could not acknowledge update. Retry.');
return false;
} finally {
marking = false;
@@ -391,7 +476,7 @@ function createNotificationReplier({
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
},
async submit(item, body) {
async submit(item, body, attachment = null) {
if (pending) return false;
pending = true;
this.saveDraft(item, body);
@@ -402,6 +487,23 @@ function createNotificationReplier({
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
onStatus('Sending reply…');
try {
if (attachment) {
onStatus('Saving screenshot for durable delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
});
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
if (!delivery.confirmed?.length) {
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') return false;
onStatus('Queued for sync when the connection returns.');
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
@@ -467,10 +569,67 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
function createWorkSessionCheckpoint({
storage,
getLogin,
onError = () => {},
key = 'stackchain.today-session.v1',
}) {
const login = () => String(getLogin() || '').trim();
let errorReported = false;
const reportError = error => {
if (errorReported) return;
errorReported = true;
onError(error);
};
const read = () => {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
!Number.isInteger(value.index) || value.index < 0) return null;
return value;
} catch (error) {
reportError(error);
return null;
}
};
return {
read,
save(identity, index) {
const owner = login();
if (!owner) return false;
try {
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
return true;
} catch (error) {
reportError(error);
return false;
}
},
clear() {
if (!read()) return false;
try {
storage.removeItem(key);
return true;
} catch (error) {
reportError(error);
return false;
}
},
};
}
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@@ -480,9 +639,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
can_next: index < items.length - 1,
});
const finish = () => {
const clearCheckpoint = durable;
running = false;
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
onFinish();
return false;
};
@@ -490,6 +652,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@@ -497,11 +660,40 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
return {
active: () => running,
start() {
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume(requested = null) {
const saved = checkpoint?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
return openAt(items, 0);
const exact = items.findIndex(item => workIdentity(item) ===
(requested ? workIdentity(requested) : saved.identity));
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
reconcile() {
if (!running) return false;
@@ -518,21 +710,48 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
next(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
complete(requested = null) {
if (!running) return false;
const items = queue();
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
items: () => queue().slice(),
target(action) {
const items = queue();
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
}
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
if (action === 'continue') return current >= 0 ? items[current] : null;
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
if (action === 'complete') {
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
}
return null;
},
};
}
@@ -606,6 +825,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
@@ -618,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
buildMyWork.notificationIds = notificationIds;
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
buildMyWork.createNotificationSelection = createNotificationSelection;
buildMyWork.createNotificationPager = createNotificationPager;
buildMyWork.createWorkPager = createWorkPager;
buildMyWork.createNotificationReader = createNotificationReader;
+125
View File
@@ -0,0 +1,125 @@
function createNotificationReadOutbox({
storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
}) {
const storageKey = 'stackchain.notification-read-outbox.v1';
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item?.kind === 'notification-read' &&
Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
} catch (_error) { return []; }
}
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
coordinator?.notify('notification-read');
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains available. */ });
}
}
function itemId(ownerLogin, notificationId) {
return 'notification-read:' + ownerLogin + ':' + notificationId;
}
async function enqueueDurably(notificationId) {
notificationId = Number(notificationId);
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
const items = read();
const id = itemId(ownerLogin, notificationId);
let item = items.find(candidate => candidate.id === id);
if (!item) {
if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
item = {
id, kind: 'notification-read', notificationId, ownerLogin,
status: 'queued', queuedAt: Number(now()),
};
items.push(item);
write(items, false);
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...item }, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read(), 'notification-read');
await backgroundSync.requestSync();
return { item: { ...item }, background: true, durability: 'background' };
} catch (error) {
return { item: { ...item }, background: false, durability: 'foreground-only', error };
}
}
function suppress(items, login = getOwnerLogin()) {
const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
.map(item => item.notificationId));
return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
}
async function send(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
return { confirmed: true };
}
async function flush(currentLogin) {
currentLogin = String(currentLogin || '').trim();
const confirmed = [];
let items = read();
for (const item of items) {
if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
try {
const result = await send(item, currentLogin);
if (result?.blocked || result?.busy) continue;
if (result?.attention) {
items = items.map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
} : candidate);
write(items);
continue;
}
confirmed.push(item.notificationId);
items = items.filter(candidate => candidate.id !== item.id);
write(items);
} catch (error) {
const status = Number(error?.status || 0);
if (status >= 400 && status < 500) {
items = items.map(candidate => candidate.id === item.id ? {
...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
} : candidate);
write(items);
continue;
}
break;
}
}
return { confirmed, remaining: read() };
}
function reconcileBackground(records) {
const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
.map(item => [item.id, item]));
const items = read().flatMap(item => {
const state = states.get(item.id);
if (state?.status === 'sent') return [];
if (state?.status === 'attention') return [{
...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
}];
return [item];
});
write(items);
return items;
}
return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;
+21
View File
@@ -0,0 +1,21 @@
function createOfflineIssueBlocker({
enqueueDurably,
onQueued = () => {},
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
}) {
return async function queueOfflineIssueBlocker(item, blocker, present) {
const admission = await enqueueDurably({
kind: 'issue-blocker',
repository: String(item.repository || ''),
number: Number(item.number || 0),
blockerRepository: String(blocker.repository || ''),
blockerNumber: Number(blocker.number || 0),
present: present === true,
operationId: String(createOperationId()).slice(0, 128),
});
onQueued(item, blocker, present === true, admission);
return { admission };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
+19
View File
@@ -0,0 +1,19 @@
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
return async function closeOfflineIssue(item) {
const admission = await enqueueDurably({
kind: 'issue-close',
repository: String(item.repository || ''),
number: Number(item.number || 0),
body: '',
operationId: String(createOperationId()).slice(0, 128),
});
const advanced = Boolean(completeToday(item, {
successMessage: 'Issue closure queued. Next Today item opened.',
failureMessage: 'Issue closure queued, but Today still needs completion.',
}));
return { admission, advanced };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
+88
View File
@@ -0,0 +1,88 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.createOfflineToday = api;
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
function createOfflineToday({
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
}) {
let failed = new Set();
let generation = 0;
function bounded(items) {
return (Array.isArray(items) ? items : []).filter(item =>
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
).slice(0, Math.max(1, maxItems));
}
async function run(login, sourceItems, onlyFailed) {
login = String(login || '').trim();
const items = bounded(sourceItems);
const runGeneration = ++generation;
if (!login || !items.length) {
failed = new Set();
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
onStatus(empty);
return empty;
}
const previousFailed = failed;
const savedDetails = new Map();
await Promise.all(items.map(async item => {
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
}));
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
const snapshot = pending => ({
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
});
const candidates = items.filter(item => {
const key = itemKey(item);
if (onlyFailed && !previousFailed.has(key)) return false;
const saved = savedDetails.get(key);
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
});
failed = new Set();
onStatus(snapshot(candidates.length));
let cursor = 0;
async function worker() {
while (cursor < candidates.length && runGeneration === generation) {
const item = candidates[cursor++];
try {
const detail = await loadDetail(item);
if (runGeneration !== generation) return;
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved === false) throw new Error('Offline detail was not durably admitted.');
readyKeys.add(itemKey(item));
} catch (_error) {
if (runGeneration === generation) failed.add(itemKey(item));
}
if (runGeneration === generation) {
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
}
}
}
await Promise.all(Array.from(
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
));
if (runGeneration !== generation) return snapshot(0);
const status = snapshot(0);
onStatus(status);
return status;
}
return {
warm: (login, items) => run(login, items, false),
retry: (login, items) => run(login, items, true),
cancel() { generation += 1; failed = new Set(); },
failedKeys: () => Array.from(failed),
};
}
return createOfflineToday;
});
+340 -3
View File
@@ -7,6 +7,7 @@
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
const VERSION = 1;
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const ITEM_FIELDS = [
@@ -16,6 +17,63 @@
const NOTIFICATION_FIELDS = [
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
];
const DETAIL_FIELDS = [
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state',
];
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
const REVIEW_FILE_FIELDS = [
'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated',
];
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
if (!indexedDB) return null;
let databasePromise;
function database() {
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains('work')) {
request.result.createObjectStore('work', { keyPath: 'id' });
}
};
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => { db.close(); databasePromise = undefined; };
resolve(db);
};
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
});
return databasePromise;
}
const requested = request => new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('work', 'readwrite');
const store = transaction.objectStore('work');
let result;
let workError;
transaction.oncomplete = () => workError ? undefined : resolve(result);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
Promise.resolve(work({
get: key => requested(store.get(key)),
getAll: () => requested(store.getAll()),
put: value => requested(store.put(value)),
delete: key => requested(store.delete(key)),
clear: () => requested(store.clear()),
})).then(value => { result = value; }).catch(error => {
workError = error;
try { transaction.abort(); } catch (_) { reject(error); }
});
});
};
}
function pick(source, fields) {
const output = {};
@@ -25,18 +83,272 @@
return output;
}
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
function createOfflineWorkStore({
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
maxDetails = 10, initializationTimeoutMs = 1500,
}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
let migrationPromise;
let readinessPromise;
let readinessGeneration = 0;
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
let recordCache = null;
let pendingDurableClear = false;
function hydrateLegacyCache() {
const cache = new Map();
try {
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
if (Array.isArray(details)) details.forEach(record => {
if (!record?.key || !record?.user_login || !record?.data) return;
const id = 'detail:' + record.user_login + ':' + record.key;
cache.set(id, { ...record, id });
});
} catch (_) { cache.clear(); }
recordCache = cache;
}
function migrateLegacy(generation) {
if (!transact) return Promise.resolve(true);
if (!migrationPromise) migrationPromise = (async () => {
let snapshot = null;
let details = [];
try {
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
details = Array.isArray(parsedDetails) ? parsedDetails : [];
} catch (_) {
return false;
}
if (!snapshot && !details.length) return true;
await transact(async records => {
if (snapshot && !(await records.get('snapshot'))) {
await records.put({ ...snapshot, id:'snapshot' });
}
for (const record of details.slice(-Math.max(1, maxDetails))) {
if (!record?.key || !record?.user_login || !record?.data) continue;
const id = 'detail:' + record.user_login + ':' + record.key;
if (!(await records.get(id))) await records.put({ ...record, id });
}
});
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
return true;
})().catch(() => false);
return migrationPromise;
}
async function initialize(generation) {
const migrated = await migrateLegacy(generation);
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
if (pendingDurableClear) {
await transact(async records => { await records.clear(); });
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
pendingDurableClear = false;
recordCache = new Map();
return true;
}
return transact(async records => {
const currentTime = now().getTime();
const valid = [];
for (const record of await records.getAll()) {
const savedAt = Date.parse(record?.saved_at || '');
const validShape = record?.id === 'snapshot' ?
record.version === VERSION && record.user_login && record.data :
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
if (record?.id) await records.delete(record.id);
} else valid.push(record);
}
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
recordCache = new Map(valid.map(record => [record.id, record]));
return true;
});
}
function startReady() {
const generation = ++readinessGeneration;
readinessState = { state:'initializing', reason:'' };
const operation = initialize(generation).catch(() => false);
readinessPromise = new Promise(resolve => {
const timer = setTimeout(() => {
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'deadline' };
resolve(false);
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
operation.then(available => {
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
clearTimeout(timer);
if (available) readinessState = { state:'ready', reason:'' };
else {
hydrateLegacyCache();
readinessState = { state:'degraded', reason:'unavailable' };
}
resolve(Boolean(available));
});
});
return readinessPromise;
}
function ready() {
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
if (readinessState.state === 'degraded') return Promise.resolve(false);
return readinessPromise || startReady();
}
function status() {
return { ...readinessState };
}
function retry() {
if (!transact) return Promise.resolve(true);
migrationPromise = undefined;
readinessPromise = undefined;
return startReady();
}
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
try { storage.removeItem(SNAPSHOT_KEY); }
if (transact) {
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
} catch (_) { /* IndexedDB remains the source of truth. */ }
if (readinessState.state === 'degraded') {
pendingDurableClear = true;
recordCache = new Map();
return Promise.resolve(true);
}
migrationPromise = Promise.resolve(true);
return transact(async records => {
await records.clear();
recordCache = new Map();
return true;
}).catch(() => false);
}
try {
storage.removeItem(SNAPSHOT_KEY);
storage.removeItem(DETAILS_KEY);
}
catch (_) { return false; }
return true;
}
function detailKey(item) {
if (item?.kind === 'update') {
const notificationId = Number(item?.notification_id || 0);
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = item?.kind === 'review' ? 'review' :
item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
const repository = String(item?.repository || '');
const number = Number(item?.number || 0);
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
}
function readDetails() {
try {
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
return Array.isArray(value) ? value : [];
} catch (_) {
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
return [];
}
}
function saveDetail(login, item, detail) {
const key = detailKey(item);
login = String(login || '').trim();
if (!enabled() || !login || !key || !detail) return false;
const conversation = detail.conversation || {};
const data = {
...pick(detail, DETAIL_FIELDS),
conversation: {
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
page: Number(conversation.page || 1),
older_page: conversation.older_page ?? null,
total: Number(conversation.total || 0),
},
};
if (item?.is_review || item?.kind === 'review') {
data.files = (detail.files || []).slice(0, 50).map(file => ({
...pick(file, REVIEW_FILE_FIELDS),
diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)),
}));
data.reviews = (detail.reviews || []).slice(-20).map(review => ({
...pick(review, REVIEW_FIELDS),
user: pick(review?.user, ['login']),
}));
}
const record = {
key,
user_login: login,
saved_at: now().toISOString(),
data,
};
if (transact) {
record.id = 'detail:' + login + ':' + key;
return ready().then(available => available ? transact(async records => {
const existing = (await records.getAll()).filter(candidate =>
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
);
await records.put(record);
const overflow = existing.concat(record).sort((a, b) =>
String(a.saved_at).localeCompare(String(b.saved_at))
).slice(0, -Math.max(1, maxDetails));
for (const candidate of overflow) await records.delete(candidate.id);
overflow.forEach(candidate => recordCache.delete(candidate.id));
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
const records = readDetails().filter(candidate =>
!(candidate?.key === key && candidate?.user_login === login)
);
records.push(record);
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
catch (_) { return false; }
return true;
}
function loadDetail(login, item) {
const key = detailKey(item);
login = String(login || '').trim();
if (!login || !key) return null;
if (transact) {
const id = 'detail:' + login + ':' + key;
if (!recordCache) return ready().then(() => loadDetail(login, item));
const record = recordCache.get(id);
if (!record) return null;
const savedAt = Date.parse(record.saved_at || '');
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
recordCache.delete(id);
transact(async records => { await records.delete(id); }).catch(() => {});
return null;
}
return { ...record.data, saved_at: record.saved_at };
}
const records = readDetails();
const currentTime = now().getTime();
const valid = records.filter(record => {
const savedAt = Date.parse(record?.saved_at || '');
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
currentTime - savedAt <= maxAgeMs;
});
if (valid.length !== records.length) {
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
}
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
return record ? { ...record.data, saved_at: record.saved_at } : null;
}
function setEnabled(value) {
try {
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
@@ -48,6 +360,7 @@
function save(snapshot) {
if (!enabled() || !snapshot?.user?.login) return false;
const record = {
id: 'snapshot',
version: VERSION,
user_login: String(snapshot.user.login),
saved_at: now().toISOString(),
@@ -60,12 +373,36 @@
notification_pagination: snapshot.notification_pagination || {},
},
};
if (transact) {
return ready().then(available => available ? transact(async records => {
await records.put(record);
recordCache.set(record.id, record);
return true;
}) : false).catch(() => false);
}
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
catch (_) { return false; }
return true;
}
function load(expectedLogin) {
if (transact) {
if (!recordCache) return ready().then(() => load(expectedLogin));
const record = recordCache.get('snapshot');
const savedAt = Date.parse(record?.saved_at || '');
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
!Number.isFinite(savedAt);
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
if (invalid || expired) {
if (record) {
recordCache.delete('snapshot');
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
}
return null;
}
if (expectedLogin && record.user_login !== expectedLogin) return null;
return { ...record.data, saved_at: record.saved_at };
}
let record;
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
catch (_) { clear(); return null; }
@@ -81,7 +418,7 @@
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, clear };
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
}
return createOfflineWorkStore;
+1 -1
View File
@@ -15,7 +15,7 @@ function createOutboxCoordinator({
let sequence = 0;
function validChange(value) {
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
}
function publish(change) {
+63
View File
@@ -0,0 +1,63 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createPlanTodayPreview = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createPlanTodayPreview({
planner,
identity,
getScroll = () => 0,
setScroll = () => {},
onOpen = () => {},
onClose = () => {},
}) {
let current = null;
function open(item, trigger = null) {
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
current = { item, trigger, scroll:Number(getScroll()) || 0 };
onOpen(item, trigger);
return true;
}
function setDependencies({ available, dependencies } = {}) {
if (!current) return false;
current.dependencies_available = available === true;
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
return true;
}
function close({ add = false, override = false } = {}) {
if (!current) return 'closed';
if (add && current.requires_override && !override) {
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
}
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
const id = identity(item);
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
}
current = null;
setScroll(scroll);
onClose(item, trigger);
return result;
}
function snapshot() {
if (!current) return { open:false };
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
state.dependencies_available = current.dependencies_available;
state.dependencies = current.dependencies;
state.requires_override = current.requires_override;
}
return state;
}
return { open, close, setDependencies, snapshot };
};
});
+132
View File
@@ -0,0 +1,132 @@
function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
let capacityMinutes = null;
let estimates = {};
let capacityAware = false;
function cleanItems(items) {
const unique = new Map();
for (const item of items || []) {
const id = identity?.(item);
if (id && !unique.has(id)) unique.set(id, item);
}
return unique;
}
function open(selectedItems, candidates, planning = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
const id = identity?.(item);
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
}
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
? planning.capacity_minutes : null;
estimates = {};
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
}
openState = true;
return snapshot();
}
function toggle(item) {
if (!openState) return 'closed';
const id = identity?.(item);
if (!id) return 'unavailable';
itemsById.set(id, item);
const index = draftIds.indexOf(id);
if (index >= 0) {
draftIds.splice(index, 1);
return 'removed';
}
if (draftIds.length >= limit) return 'full';
draftIds.push(id);
return 'added';
}
function move(id, direction) {
const index = draftIds.indexOf(id);
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
return true;
}
function close() {
openState = false;
draftIds = [];
itemsById = new Map();
capacityMinutes = null;
estimates = {};
capacityAware = false;
}
function cancel() {
close();
return true;
}
function setCapacity(minutes) {
capacityAware = true;
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
return snapshot();
}
function setEstimate(id, minutes) {
if (!openState || !draftIds.includes(id)) return false;
capacityAware = true;
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
else delete estimates[id];
return true;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
const state = snapshot();
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
const payload = capacityAware ? {
ids,
capacity_minutes: capacityMinutes,
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
} : ids;
if (save?.(payload) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
return 'saved';
}
function snapshot() {
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
if (!capacityAware) return basic;
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
return {
...basic,
capacity_minutes: capacityMinutes,
estimates: selectedEstimates,
planned_minutes: plannedMinutes,
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
};
}
function item(id) {
return itemsById.get(id) || null;
}
function candidates() {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+62
View File
@@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.stackchainPrivateDeviceData = factory({
localStorage: root.localStorage,
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
});
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
}) {
function removeOwnedStorage(storage) {
if (!storage) return;
const keys = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (key?.startsWith('stackchain.')) keys.push(key);
}
keys.forEach(key => storage.removeItem(key));
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
return async function clearPrivateDeviceData() {
removeOwnedStorage(localStorage);
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
};
});
+20 -4
View File
@@ -5,7 +5,11 @@ function mergeEligibility(detail, reviewState) {
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
if (detail.ci_state !== 'success') {
return { allowed: false, reason: 'CI must succeed before merging' };
const blockers = (detail.checks || []).filter(check =>
['failure', 'error', 'pending'].includes(check.state)
).map(check => check.name).filter(Boolean).slice(0, 3);
return { allowed: false, reason: blockers.length ?
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
}
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
if (reviewState && !reviewState.complete) {
@@ -40,6 +44,7 @@ function renderFile(file, index, reviewed, escapeHtml) {
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
@@ -64,9 +69,9 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
load(item) {
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
},
loadReview(item, headSha) {
loadReview(item, headSha, { refresh = false } = {}) {
const key = item.repository + '#' + item.number + ':' + headSha;
if (reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
if (reviewRequests.has(key)) return reviewRequests.get(key);
const request = fetchJson(pathFor(item) + '/review-data', {
headers: { Accept: 'application/json' },
@@ -78,6 +83,13 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
reviewRequests.set(key, request);
return request;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { checkRequest = null; });
return checkRequest;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
@@ -139,7 +151,11 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
if (!result?.merged) {
throw new Error(result?.confirmation_pending && result?.error
? result.error
: 'Pull request merge was not confirmed.');
}
return result;
}).finally(() => { mergeRequest = null; });
return mergeRequest;
+17
View File
@@ -0,0 +1,17 @@
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
return function queueToday(issue) {
const added = todayWork.add(issue);
if (added !== 'added' && added !== 'exists') return added;
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
refresh();
warm();
return 'sync-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
return 'queued';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
+81 -8
View File
@@ -4,6 +4,7 @@ function createReviewController({
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
}) {
let pendingSubmission = null;
let pendingChecks = null;
function endpoint(item) {
const [owner, repo] = String(item.repository || '').split('/');
@@ -18,6 +19,14 @@ function createReviewController({
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
}
function loadChecks(item) {
if (pendingChecks) return pendingChecks;
pendingChecks = fetchJson(endpoint(item) + '/checks', {
headers: { Accept: 'application/json' },
}).finally(() => { pendingChecks = null; });
return pendingChecks;
}
function submit(item, payload) {
if (pendingSubmission) return pendingSubmission;
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
@@ -44,7 +53,29 @@ function createReviewController({
return pendingSubmission;
}
return { load, submit };
return { load, loadChecks, submit };
}
function createWrapPreference({ storage, mobile }) {
const key = 'stackchain.review-wrap.v1';
let explicit = false;
let wrapped = Boolean(mobile);
try {
const saved = storage?.getItem(key);
if (saved === 'true' || saved === 'false') {
explicit = true;
wrapped = saved === 'true';
}
} catch (_error) { /* keep the viewport default */ }
function snapshot() { return { wrapped, explicit }; }
function setWrapped(value) {
wrapped = Boolean(value);
explicit = true;
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
return snapshot();
}
return { snapshot, setWrapped, storageKey: key };
}
function diffLineClass(line) {
@@ -70,16 +101,19 @@ function parseDiffLines(lines) {
return { text, kind: diffLineClass(text), commentable: false };
}
if (text.startsWith('-')) {
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
oldLine += 1;
return row;
}
if (text.startsWith('+')) {
const row = { text, kind: 'added', commentable: true, new_position: newLine };
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
newLine += 1;
return row;
}
const row = { text, kind: 'context', commentable: true, new_position: newLine };
const row = {
text, kind: 'context', commentable: true,
old_line: oldLine, new_line: newLine, new_position: newLine,
};
oldLine += 1;
newLine += 1;
return row;
@@ -91,8 +125,14 @@ function renderDiffFile(file, index, escapeHtml) {
let preview;
if (file.diff_available) {
const lines = parseDiffLines(file.diff_lines).map(row => {
const oldNumber = row.old_line || row.old_position || '';
const newNumber = row.new_line || row.new_position || '';
const content = '<span class="review-line-numbers" aria-hidden="true">' +
'<span class="review-line-number old">' + oldNumber + '</span>' +
'<span class="review-line-number new">' + newNumber + '</span></span>' +
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
if (!row.commentable) {
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
}
const position = row.old_position
? ' data-old-position="' + row.old_position + '"'
@@ -100,7 +140,7 @@ function renderDiffFile(file, index, escapeHtml) {
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
(row.old_position || row.new_position) + '">' + content + '</button>';
}).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
@@ -169,7 +209,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed, clear };
return { snapshot, markReviewed, clear, storageKey: key };
}
function createDraft({ storage, repository, number, headSha, files }) {
@@ -267,7 +307,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
return {
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
storageKey: key,
};
}
function formatFeedback(draft, files) {
@@ -321,7 +364,36 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
return true;
}
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
const items = (Array.isArray(checks) ? checks : []).filter(check =>
check && typeof check.name === 'string' && check.name
).map((check, index) => ({ ...check, index })).sort((left, right) =>
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
);
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
const pending = items.filter(check => check.state === 'pending').length;
const passed = items.filter(check => check.state === 'success').length;
const other = items.length - failed - pending - passed;
const parts = [
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
passed ? passed + ' passed' : '', other ? other + ' other' : '',
].filter(Boolean);
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
const html = items.map(check => {
const url = typeof check.url === 'string' ? check.url : '';
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
'<span class="small">' + escapeHtml(check.state || 'unknown') +
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
}).join('');
return { summary, html, expanded: failed > 0 };
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
@@ -329,6 +401,7 @@ createReviewController.createDraft = createDraft;
createReviewController.formatFeedback = formatFeedback;
createReviewController.copyAndContinue = copyAndContinue;
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
createReviewController.renderChecks = renderChecks;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createReviewController;
+186 -12
View File
@@ -1,67 +1,219 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v19';
const CACHE = 'stackchain-dashboard-shell-v87';
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;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/feature-loader.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/offline-issue-blocker.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
BASE + 'static/update-reply-read-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/later-and-start.js',
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
BASE + 'static/queue-today.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/background-issue-sync.js',
];
async function sessionCsrf() {
async function fetchNavigation(request) {
const controller = new AbortController();
let timeout;
const deadline = new Promise((resolve, reject) => {
timeout = setTimeout(() => {
controller.abort();
reject(new Error('Dashboard navigation timed out.'));
}, NAVIGATION_TIMEOUT_MS);
});
try {
return await Promise.race([
fetch(request, { signal: controller.signal }),
deadline,
]);
} finally {
clearTimeout(timeout);
}
}
async function sessionCsrf(signal) {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) return '';
const payload = await response.json().catch(() => ({}));
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
}
let batchedCsrf = null;
let batchedCsrfController = null;
async function withSessionCsrf(work) {
batchedCsrfController = new AbortController();
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
try {
return await work();
} finally {
batchedCsrfController.abort();
batchedCsrf = null;
batchedCsrfController = null;
}
}
async function purgeRevokedSessionData() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function notifyIdleSession() {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
}
async function storeOfflineLease(expiresAt, idleExpiresAt) {
if (
!Number.isInteger(expiresAt) || expiresAt <= 0
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function offlineLeaseState(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
const current = Math.floor(Date.now() / 1000);
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
return 'valid';
}
async function expiredOfflineResponse() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
return new Response(
'Your Stackchain session expired. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function idleOfflineResponse() {
await notifyIdleSession();
return new Response(
'Your Stackchain session is locked. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
const state = await offlineLeaseState(cache);
if (state === 'expired') return expiredOfflineResponse();
if (state === 'idle') return idleOfflineResponse();
return cache.match(BASE);
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(options.headers || {});
const csrf = await sessionCsrf();
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
if (batchedCsrf && options.signal) {
if (options.signal.aborted) cancelBatchedCsrf();
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
}
let csrf;
try {
csrf = await (batchedCsrf || sessionCsrf(options.signal));
} finally {
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
}
if (options.signal?.aborted) throw new Error('Background request aborted.');
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetch(new URL(url, self.location.origin), requestOptions);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
const code = payload.code || payload.detail?.code;
if (response.status === 401 && code === 'session_revoked') {
await purgeRevokedSessionData();
}
if (response.status === 401 && code === 'session_idle') {
await notifyIdleSession();
}
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
error.code = code;
throw error;
}
return payload;
}
const issueSync = self.__issueSync || createBackgroundIssueSync({
store: createIssueSyncStore(), fetchJson, base: BASE,
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
});
async function flushAndNotify() {
@@ -70,11 +222,13 @@ async function flushAndNotify() {
!await issueSync.getReceiptPreference?.(result.login)) return;
for (const receipt of result.receipts) {
const needsAttention = receipt.status === 'attention';
const title = needsAttention
const needsAuthorization = receipt.status === 'authorization';
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
? 'Queued work needs attention'
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
await self.registration.showNotification(title, {
body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
});
@@ -97,7 +251,21 @@ self.addEventListener('sync', event => {
});
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
})());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
}
})());
});
self.addEventListener('notificationclick', event => {
@@ -121,18 +289,24 @@ self.addEventListener('fetch', event => {
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).then(async response => {
fetchNavigation(request).then(async response => {
const cache = await caches.open(CACHE);
if (response.ok) {
const responseUrl = new URL(response.url || request.url);
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
if (cached) return cached;
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
return cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
);
})
);
return;
+674 -8
View File
@@ -13,24 +13,304 @@
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
credentials: root.navigator?.credentials,
MessageChannel: root.MessageChannel,
location: root.location,
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
confirmAction: message => root.confirm(message),
promptAuthorization: details => root.prompt(
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
),
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
onClearError: error => {
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
},
});
root.fetch = boundary.fetch;
const attach = () => {
boundary.startActivityHeartbeat();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
boundary.resumeQueuedWork();
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status');
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
const closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey');
const activityList = root.document.getElementById('security-activity-list');
const activityStatus = root.document.getElementById('security-activity-status');
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
let activityCursor = null;
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
try {
const devices = await boundary.listActiveDevices();
devices.forEach(device => {
const row = root.document.createElement('article');
row.className = 'active-device';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = device.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
details.append(label, timing);
if (device.current) {
const current = root.document.createElement('span');
current.className = 'active-device-current';
current.textContent = 'This device';
details.append(current);
}
const revoke = root.document.createElement('button');
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
});
row.append(details, revoke);
devicesList.append(row);
});
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
} catch (_error) {
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
const renderPasskeys = async () => {
if (!passkeysList || !passkeysStatus) return;
passkeysStatus.textContent = 'Loading enrolled passkeys…';
passkeysList.replaceChildren();
try {
const enrolled = await boundary.listPasskeys();
enrolled.forEach(passkey => {
const row = root.document.createElement('article');
row.className = 'enrolled-passkey';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = passkey.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
details.append(label, timing);
const remove = root.document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove passkey';
remove.addEventListener('click', async () => {
remove.disabled = true;
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
try {
const outcome = await boundary.revokePasskey(passkey);
if (outcome) {
passkeysStatus.textContent = outcome.current_session
? 'Passkey removed. This session remains active; keep your recovery token available.'
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
}
} catch (_error) {
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
remove.disabled = false;
}
});
row.append(details, remove);
passkeysList.append(row);
});
passkeysStatus.textContent = enrolled.length
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
: 'No passkeys enrolled.';
} catch (_error) {
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
}
};
const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
loadMoreActivity.hidden = true;
if (!append) {
activityCursor = null;
activityList.replaceChildren();
}
try {
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
if (!append) {
page.authentication_alerts.forEach(alert => {
const row = root.document.createElement('article');
row.className = 'security-event authentication-alert';
const formatted = boundary.formatAuthenticationAlert(alert);
const title = root.document.createElement('strong');
title.textContent = formatted.title;
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
activityList.append(row);
});
}
const labels = {
sign_in: 'Signed in',
sign_out: 'Signed out',
passkey_enrolled: 'Passkey enrolled',
device_revoked: 'Device access revoked',
all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed',
pull_merged: 'Pull request merged',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested',
};
page.events.forEach(event => {
const row = root.document.createElement('article');
row.className = 'security-event';
const title = root.document.createElement('strong');
title.textContent = labels[event.kind] || 'Security event';
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [
event.device_label,
event.method,
event.target,
event.status === 'pending' ? 'Outcome confirmation pending' : null,
].filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
row.append(title, details);
activityList.append(row);
});
activityCursor = page.next_cursor;
activityStatus.textContent = activityList.children.length
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
: 'No security activity yet.';
loadMoreActivity.textContent = 'Load older activity';
loadMoreActivity.hidden = !activityCursor;
} catch (_error) {
activityStatus.textContent = 'Security activity could not be loaded.';
loadMoreActivity.textContent = 'Retry activity';
loadMoreActivity.hidden = false;
}
};
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
devicesSheet.hidden = false;
closeDevices?.focus();
renderDevices();
renderPasskeys();
renderSecurityActivity();
});
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
devicesButton?.focus();
});
enrollPasskey?.addEventListener('click', async () => {
enrollPasskey.disabled = true;
devicesStatus.textContent = 'Waiting for your device passkey…';
try {
await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
await renderPasskeys();
} catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally {
enrollPasskey.disabled = false;
}
});
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
});
};
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach();
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
activityHeartbeatIntervalMs = 60000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
let idleLockStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null;
async function handleRemoteRevocation() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
location.replace(base + 'login?reason=session-revoked');
}
async function handleSessionExpiry() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
onExpired();
location.replace(base + 'login?reason=session-expired');
}
function handleSessionIdle() {
if (expirationStarted || idleLockStarted) return;
idleLockStarted = true;
location.replace(base + 'login?reason=session-idle');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-idle') {
handleSessionIdle();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
}
function storedOfflineLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function storedOfflineIdleLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
function scheduleOfflineIdle(idleExpiresAt) {
const delay = Math.max(0, idleExpiresAt * 1000 - now());
setTimer(() => {
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
}, delay);
}
async function publishOfflineLease(expiresAt, idleExpiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
});
}
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
@@ -39,12 +319,120 @@
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
}
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
let binary = '';
new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function authenticationCredentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; }
}
async function sessionFetch(input, options = {}) {
function isDashboardApi(input) {
try {
const url = new URL(String(input?.url || input), origin);
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
} catch (_error) { return false; }
}
async function fetchWithDeadline(input, options, method, phase = 'request') {
if (!isDashboardApi(input)) return fetchImpl(input, options);
const controller = new AbortController();
const callerSignal = options.signal || input?.signal;
let timeout;
let rejectCancellation;
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
const cancelFromCaller = () => {
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
error.source = 'caller';
controller.abort(error);
rejectCancellation(error);
};
if (callerSignal?.aborted) cancelFromCaller();
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
const deadline = new Promise((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(
SAFE_METHODS.has(method)
? 'Request timed out. Try again.'
: 'Request timed out. Refresh to verify the outcome before retrying.'
);
error.name = 'TimeoutError';
error.method = method;
error.outcome = 'unknown';
error.safeToRetry = SAFE_METHODS.has(method);
error.phase = phase;
controller.abort(error);
reject(error);
}, requestTimeoutMs);
});
try {
return await Promise.race([
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
deadline,
cancellation,
]);
} finally {
clearTimeout(timeout);
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
}
}
async function authorizeWithPasskey(details) {
if (!credentials?.get) return null;
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' });
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', {
method: 'POST', headers,
body: JSON.stringify({ action: details.action, target: details.target }),
}, 'POST', 'passkey-authorization');
if (!optionsResponse.ok) return null;
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', {
method: 'POST', headers,
body: JSON.stringify({
challenge,
credential: authenticationCredentialJSON(credential),
action: details.action,
target: details.target,
}),
}, 'POST', 'passkey-authorization');
if (!verified.ok) return null;
const payload = await verified.json().catch(() => ({}));
return payload.grant || null;
}
async function sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
@@ -53,11 +441,124 @@
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
if (response.status === 401) onExpired();
const response = await fetchWithDeadline(input, requestOptions, method);
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
const payload = await response.clone().json().catch(() => ({}));
const detail = payload?.detail || {};
if (detail.code === 'step_up_required' && detail.action && detail.target) {
let grant = null;
try { grant = await authorizeWithPasskey(detail); }
catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
if (!grant) {
const accessToken = await promptAuthorization({
action: detail.action,
target: detail.target,
});
if (!accessToken) return response;
const authorizationHeaders = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
action: detail.action,
target: detail.target,
}),
}, 'POST', 'fresh-authorization');
if (!authorization.ok) return authorization;
const authorizationPayload = await authorization.json().catch(() => ({}));
grant = authorizationPayload.grant;
}
if (!grant) return response;
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
retryHeaders.set('X-Step-Up-Grant', grant);
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
}
}
if (
response.status === 401 && isSameOrigin(input)
&& !expirationStarted && !idleLockStarted
) {
expirationStarted = true;
const payload = await response.clone().json().catch(() => ({}));
if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else if (payload.code === 'session_idle') {
expirationStarted = false;
handleSessionIdle();
} else {
expirationStarted = false;
await handleSessionExpiry();
}
}
return response;
}
function recordActivity() {
const current = now();
if (
expirationStarted
|| idleLockStarted
|| activityHeartbeat
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
) return Promise.resolve(false);
lastActivityHeartbeatAt = current;
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
.then(async response => {
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
const expiresAt = storedOfflineLease();
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
await publishOfflineLease(expiresAt, payload.idle_expires_at);
}
return true;
})
.catch(() => false)
.finally(() => { activityHeartbeat = null; });
return activityHeartbeat;
}
function startActivityHeartbeat() {
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
addActivityListener?.(type, recordActivity, { passive: true });
});
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
if (
!payload.authenticated
|| !Number.isInteger(payload.expires_at)
|| !Number.isInteger(payload.idle_expires_at)
) return false;
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 <= now()) {
if (expiresAt) await handleSessionExpiry();
return false;
}
const idleExpiresAt = storedOfflineIdleLease();
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
handleSessionIdle();
return false;
}
scheduleOfflineExpiry(expiresAt);
scheduleOfflineIdle(idleExpiresAt);
return true;
}
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
@@ -70,17 +571,155 @@
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateDatabase(name) {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
throw error;
}
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
}
async function enrollPasskey() {
if (!credentials?.create) throw new Error('Passkeys are not supported');
const optionsResponse = await sessionFetch(
base + 'api/v1/passkeys/registration/options', { method: 'POST' }
);
if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.user.id = decodeBase64Url(publicKey.user.id);
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.create({ publicKey });
const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
attestationObject: encodeBase64Url(credential.response.attestationObject),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
transports: credential.response.getTransports?.() || [],
},
},
}),
});
if (!response.ok) throw new Error('Could not verify passkey enrollment');
return true;
}
async function listActiveDevices() {
const response = await sessionFetch(base + 'api/v1/sessions');
if (!response.ok) throw new Error('Could not load active devices');
const payload = await response.json();
return Array.isArray(payload.devices) ? payload.devices : [];
}
async function listPasskeys() {
const response = await sessionFetch(base + 'api/v1/passkeys');
if (!response.ok) throw new Error('Could not load enrolled passkeys');
const payload = await response.json();
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
}
async function revokePasskey(passkey) {
if (!passkey?.management_id) return false;
const confirmed = confirmAction?.(
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
);
if (!confirmed) return false;
const response = await sessionFetch(
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error('Could not remove enrolled passkey');
return response.json();
}
function formatAuthenticationAlert(alert) {
const failed = Math.max(0, Number(alert?.failed_count) || 0);
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
+ (blocked ? ` · ${blocked} blocked` : '');
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
'en-US', { timeZone: 'UTC' },
);
return {
title,
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
};
}
async function listSecurityEvents(cursor = null) {
const query = new URLSearchParams({ limit: '25' });
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
if (!response.ok) throw new Error('Could not load security activity');
const payload = await response.json();
return {
events: Array.isArray(payload.events) ? payload.events : [],
authentication_alerts: Array.isArray(payload.authentication_alerts)
? payload.authentication_alerts : [],
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
};
}
async function revokeActiveDevice(device) {
if (!device?.management_id || device.current) return false;
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
if (!confirmed) return false;
const response = await sessionFetch(
base + 'api/v1/sessions/' + encodeURIComponent(device.management_id),
{ method: 'DELETE' },
);
if (!response.ok) throw new Error('Could not revoke active device');
return true;
}
async function signOut() {
try {
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
@@ -90,6 +729,16 @@
}
}
async function signOutAllDevices() {
const confirmed = 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');
await clearPrivateDeviceData();
location.assign(base + 'login');
return true;
}
async function resumeQueuedWork() {
try {
const registration = await serviceWorker?.ready;
@@ -97,5 +746,22 @@
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
return {
fetch: sessionFetch,
signOut,
signOutAllDevices,
listActiveDevices,
listPasskeys,
listSecurityEvents,
formatAuthenticationAlert,
enrollPasskey,
revokeActiveDevice,
revokePasskey,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
recordActivity,
startActivityHeartbeat,
};
});
+60
View File
@@ -0,0 +1,60 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createTaskOverlayHistory = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
let started = false;
function stateKind(state = history.state) {
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
}
function apply(state) {
const next = stateKind(state);
if (next === active) return;
const previous = active;
active = next;
onChange(next, previous);
}
return {
start() {
if (started) return;
started = true;
eventTarget.addEventListener('popstate', event => apply(event.state));
},
open(kind) {
if (!allowed.has(kind)) return false;
if (active === kind) return true;
const previous = active;
const state = { ...(history.state || {}), taskOverlay: kind };
history.pushState(state, '');
active = kind;
onChange(kind, previous);
return true;
},
close() {
if (!active) return false;
history.back();
return true;
},
leave() {
if (!active) return false;
const previous = active;
const state = { ...(history.state || {}) };
delete state.taskOverlay;
history.replaceState(state, '');
active = null;
onChange(null, previous);
return true;
},
current() { return active; },
};
};
});
+17
View File
@@ -0,0 +1,17 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refresh();
warm();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createTodayReadiness = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
let pending = null;
let generation = 0;
async function classify(item) {
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
try {
const detail = await inspect(item);
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
const dependencies = Array.isArray(detail.dependencies)
? detail.dependencies.filter(dependency => dependency?.state === 'open')
: [];
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
} catch (_error) {
return { status:'unknown', dependencies:[] };
}
}
async function run(action, items, target = null) {
const request = ++generation;
const ordered = Array.isArray(items) ? items.slice() : [];
const preferred = target || ordered[0] || null;
if (!preferred) return 'empty';
const preferredIndex = ordered.indexOf(preferred);
const candidates = preferredIndex >= 0
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
: [preferred].concat(ordered);
const targetState = { item:preferred, ...(await classify(preferred)) };
if (request !== generation) return 'superseded';
if (targetState.status === 'ready') {
pending = null;
onOpen(action, preferred);
return 'opened';
}
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
item,
...(await classify(item)),
})));
const states = [targetState, ...fallbackStates];
if (request !== generation) return 'superseded';
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
pending = {
action,
items:ordered,
target:preferred,
status:targetState.status,
dependencies:targetState.dependencies,
nextReady:nextReadyState?.item || null,
};
onGate({ ...pending, items:pending.items.slice() });
return 'gated';
}
function open(item) {
if (!pending || !item) return false;
const action = pending.action;
pending = null;
generation += 1;
onOpen(action, item);
return true;
}
function previewBlocker(blocker) {
if (!pending || !blocker) return false;
const current = pending.dependencies.find(dependency =>
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
);
if (!current) return false;
onPreview(current);
return true;
}
return {
run,
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
previewBlocker,
startNextReady: () => open(pending?.nextReady),
workAnyway: () => open(pending?.target),
cancel() {
generation += 1;
pending = null;
},
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
};
};
});
+287
View File
@@ -0,0 +1,287 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.today-sync.v1.';
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
let flushing = null;
let channel = null;
let channelKey = '';
let retryTimer = null;
let retryAttempt = 0;
let expiredCount = 0;
const knownOperationKeys = new Set();
function cancelRetry() {
if (retryTimer !== null) clearTimer?.(retryTimer);
retryTimer = null;
}
function scheduleRetry(error, ownerKey) {
if (retryTimer !== null || !pending().length || !ownerKey) return;
const advised = Number(error?.retryAfter);
const delayMs = Number.isFinite(advised) && advised >= 0
? advised * 1000
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
retryAttempt += 1;
onStatus?.('retrying', { delayMs });
retryTimer = setTimer?.(async () => {
retryTimer = null;
if (key() !== ownerKey) return false;
return flush();
}, delayMs);
retryTimer?.unref?.();
}
function key() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function snapshotKey() {
const storageKey = key();
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
}
function savedRevision() {
try {
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
} catch (_error) {
return -1;
}
}
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
if (plan.revision < savedRevision()) return false;
try {
storage?.setItem(snapshotKey(), JSON.stringify({
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
}));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
onRemotePlan?.(plan);
if (broadcast) channel?.postMessage({
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
});
return true;
}
function ensureChannel() {
const storageKey = key();
if (!storageKey || channelKey === storageKey) return;
channel?.close?.();
const factory = createChannel || (globalThis.window?.BroadcastChannel
? name => new globalThis.window.BroadcastChannel(name)
: null);
channelKey = storageKey;
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
channel?.addEventListener?.('message', event => {
if (key() === storageKey) adopt(event.data, false);
});
}
function pending() {
const storageKey = key();
if (!storageKey || !storage) return [];
const recordPrefix = storageKey + '.operation.';
try {
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
if (Array.isArray(legacy)) {
legacy.forEach((operation, index) => {
if (!operation?.operation_id) return;
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
knownOperationKeys.add(recordKey);
});
if (legacy.length) storage.removeItem(storageKey);
}
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
for (let index = 0; index < Number(storage.length || 0); index += 1) {
const candidate = storage.key?.(index);
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
}
const records = [...keys].map(recordKey => {
const record = JSON.parse(storage.getItem(recordKey) || 'null');
return record ? { ...record, recordKey } : null;
}).filter(record => record?.operation);
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
now() - Number(record.queued_at) > maxOfflineMs);
expired.forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
if (expired.length) {
expiredCount += expired.length;
onStatus?.('expired', { count: expiredCount });
}
return records.filter(record => !expired.includes(record))
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
left.operation.operation_id.localeCompare(right.operation.operation_id))
.map(record => ({
...record.operation,
base_revision: Number.isInteger(record.operation.base_revision)
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
}
function removeOperation(operationId) {
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
try {
storage.removeItem(recordKey);
knownOperationKeys.delete(recordKey);
return true;
} catch (_error) {
return false;
}
}
function operationId() {
if (createOperationId) return createOperationId();
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
}
function enqueue(action, itemId, direction = null) {
const operations = pending();
if (action === 'remove' && operations.some(operation =>
operation.action === 'remove' && operation.item_id === itemId
)) {
onStatus?.('pending');
return true;
}
const operation = {
operation_id: operationId(), action, item_id: itemId, direction,
base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
let saved = false;
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
saved = true;
coordinator?.notify('today');
} catch (_error) { /* Report the persistence failure below. */ }
onStatus?.(saved ? 'pending' : 'error');
return saved;
}
function enqueueConfiguration(capacityMinutes, estimates) {
const operation = {
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
capacity_minutes: capacityMinutes, estimates: estimates || {},
base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
const marker = migrationPrefix + storageKey.slice(prefix.length);
try {
if (storage.getItem(marker)) return false;
for (const id of ids || []) enqueue('add', id);
storage.setItem(marker, '1');
return true;
} catch (_error) {
return false;
}
}
async function run() {
const ownerKey = key();
if (!ownerKey) return false;
ensureChannel();
expiredCount = 0;
try {
let operations = pending();
let plan;
let hadConflict = false;
if (!operations.length) plan = await fetchJson('api/v1/today');
while (operations.length) {
if (key() !== ownerKey) return false;
const batch = operations.slice(0, 50);
plan = await fetchJson('api/v1/today', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: batch }),
});
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
const received = hasReceipts ? [
...(plan.accepted_operation_ids || []),
...(plan.duplicate_operation_ids || []),
...(plan.rejected_operations || []).map(item => item.operation_id),
] : batch.map(item => item.operation_id);
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
for (const operationId of received) {
if (pending().some(candidate => candidate.operation_id === operationId) &&
!removeOperation(operationId)) {
throw new Error('Could not persist Today delivery receipt');
}
}
operations = pending();
}
adopt(plan);
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
retryAttempt = 0;
cancelRetry();
return !hadConflict;
} catch (error) {
if (pending().length) scheduleRetry(error, ownerKey);
else onStatus?.('error');
return false;
}
}
function flush() {
if (!flushing) {
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
}
return flushing;
}
function startLifecycle({ window: windowObject, document: documentObject }) {
windowObject?.addEventListener?.('online', flush);
documentObject?.addEventListener?.('visibilitychange', () =>
documentObject.hidden ? false : flush()
);
}
coordinator?.subscribe(change => {
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+165
View File
@@ -0,0 +1,165 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.v1.';
const planningPrefix = 'stackchain.today-planning.v1.';
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function ownerKey(keyPrefix) {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? keyPrefix + encodeURIComponent(login) : '';
}
function storageKey() {
return ownerKey(prefix);
}
function planningKey() {
return ownerKey(planningPrefix);
}
function read() {
const key = storageKey();
if (!key || !storage) return [];
try {
const value = JSON.parse(storage.getItem(key) || '[]');
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
} catch (_error) {
return [];
}
}
function write(ids) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (ids.length) storage.setItem(key, JSON.stringify(ids));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function planning() {
const key = planningKey();
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
try {
const value = JSON.parse(storage.getItem(key) || 'null');
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
? value.capacity_minutes : null;
const ids = new Set(read());
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
ids.has(id) && Number.isInteger(minutes) && minutes > 0
));
return { capacity_minutes: capacity, estimates };
} catch (_error) {
return { capacity_minutes: null, estimates: {} };
}
}
function replacePlanning(value) {
const key = planningKey();
if (!key || !storage) return false;
const ids = new Set(read());
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
? value.capacity_minutes : null;
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
ids.has(id) && Number.isInteger(minutes) && minutes > 0
));
try {
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
return true;
} catch (_error) {
return false;
}
}
function add(item) {
if (!storageKey()) return 'unavailable';
const id = identity(item);
if (!id) return 'unavailable';
const ids = read();
if (ids.includes(id)) return 'exists';
if (ids.length >= limit) return 'full';
ids.push(id);
return write(ids) ? 'added' : 'unavailable';
}
function replace(ids) {
const unique = [];
for (const id of ids || []) {
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
unique.push(id);
}
}
const saved = write(unique);
if (saved && planningKey()) replacePlanning(planning());
return saved;
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
const saved = next.length !== ids.length && write(next);
if (saved) replacePlanning(planning());
return saved;
}
function move(item, direction) {
const ids = read();
const index = ids.indexOf(identity(item));
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
if (index < 0 || target < 0 || target >= ids.length) return false;
[ids[index], ids[target]] = [ids[target], ids[index]];
return write(ids);
}
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
const available = new Map((items || []).map(item => [identity(item), item]));
const ids = read();
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
const retired = ids.filter(id => !retained.includes(id));
if (retired.length) {
const reported = onPrune?.(retired);
if (reported !== false) {
write(retained);
replacePlanning(planning());
}
}
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
function contains(item) {
return read().includes(identity(item));
}
function position(item) {
const ids = read();
const index = ids.indexOf(identity(item));
return {
can_up: index > 0,
can_down: index >= 0 && index < ids.length - 1,
};
}
function runway(items, currentIndex = 0) {
const estimates = planning().estimates;
const remaining = (items || []).slice(Math.max(0, currentIndex));
const minutes = remaining.map(item => estimates[identity(item)] || null);
return {
current_minutes: minutes[0] || null,
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+65
View File
@@ -0,0 +1,65 @@
function createUnfiledCaptures({
storage,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
);
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
}
function list() {
const currentLogin = String(getCurrentLogin() || '').trim();
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function save(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
write(items);
return item;
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
return true;
}
function resume(id, confirmedLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) throw new Error('This capture is no longer available.');
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
discard(id);
return {repository:'', title:item.title, body:item.body, labelIds:[]};
}
return {list, save, discard, resume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+158
View File
@@ -0,0 +1,158 @@
function createUpdateOwnership({
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
}) {
let detail = null;
let update = null;
let claimedItem = null;
let request = null;
function state(value) {
onState?.(value);
}
function startState(value) {
onStartState?.(value);
}
function claimed(result) {
claimedItem = {
...result,
repository: detail.repository,
kind: 'issue',
notification_id: update?.notification_id,
updated_at: update?.updated_at || result.updated_at,
has_update: true,
};
detail.issue.claimable = false;
onClaimed?.(claimedItem);
startState({ action: 'hidden', busy: false, message: '' });
return claimedItem;
}
function recoverStartedItem(item) {
recover?.(item);
startState({
action: 'hidden',
busy: false,
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
});
state({ action: 'today', busy: false, message: '' });
return 'recovery';
}
function open(nextDetail, nextUpdate) {
detail = nextDetail;
update = nextUpdate;
claimedItem = null;
request = null;
state({
action: nextDetail?.issue?.claimable ? 'claim' : 'hidden',
busy: false,
message: '',
});
startState({
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
busy: false,
message: '',
});
}
function act() {
if (request) return request;
if (claimedItem) {
const result = addToday(claimedItem);
const messages = {
added: 'Added to Today.',
exists: 'This issue is already in Today.',
full: 'Today is limited to 5 items. Remove one before adding more.',
unavailable: 'Could not save Today on this device.',
};
state({ action: 'today', busy: false, message: messages[result] || messages.unavailable });
return Promise.resolve(result);
}
if (!detail?.issue?.claimable) return Promise.resolve('hidden');
state({ action: 'claim', busy: true, message: 'Assigning…' });
request = Promise.resolve(claim({
repository: detail.repository,
number: detail.issue.number,
})).then(result => {
claimed(result);
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
return claimedItem;
}).catch(error => {
if (error?.status === 409) {
detail.issue.claimable = false;
startState({ action: 'hidden', busy: false, message: '' });
state({
action: 'hidden',
busy: false,
message: 'Someone else claimed or closed this issue. The update is still unread.',
});
return 'conflict';
}
state({
action: 'claim',
busy: false,
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
});
return 'retry';
}).finally(() => { request = null; });
return request;
}
function startNow() {
if (request) return request;
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
if (!available()) {
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
startState({ action: 'start', busy: false, message });
return Promise.resolve('full');
}
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
state({ action: 'claim', busy: true, message: '' });
request = Promise.resolve(claim({
repository: detail.repository,
number: detail.issue.number,
})).then(result => {
const item = claimed(result);
const outcome = start(item);
if (outcome === 'started') {
startState({
action: 'hidden',
busy: false,
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
});
state({ action: 'today', busy: false, message: '' });
return outcome;
}
return recoverStartedItem(item);
}).catch(error => {
if (claimedItem) {
return recoverStartedItem(claimedItem);
}
if (error?.status === 409) {
detail.issue.claimable = false;
state({ action: 'hidden', busy: false, message: '' });
startState({
action: 'hidden',
busy: false,
message: 'Someone else claimed or closed this issue. The update is still unread.',
});
return 'conflict';
}
state({ action: 'claim', busy: false, message: '' });
startState({
action: 'start',
busy: false,
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
});
return 'retry';
}).finally(() => { request = null; });
return request;
}
return { open, act, start:startNow };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
+64
View File
@@ -0,0 +1,64 @@
function createUpdateReplyReadNext({
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
}) {
let inFlight = null;
async function admit(item, body, operationId, replyConfirmed) {
const admission = await queue({
kind: 'update-reply-read',
notificationId: item.notification_id,
body,
operationId,
replyConfirmed,
});
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Reply and acknowledgement were not saved for delivery.');
}
accept(item);
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
next: await next(item),
};
}
function submit(item, body, operationId, attachment = null) {
if (inFlight) return inFlight;
inFlight = (async () => {
let replyConfirmed = false;
try {
if (attachment) {
const admission = await queue({
kind:'update-reply-read', notificationId:item.notification_id, body,
operationId, replyConfirmed:false, attachment,
});
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
const outcome = deliver ? await deliver(admission.item) : null;
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
if (remaining?.status === 'attention') {
throw new Error(remaining.error || 'Reply and screenshot need attention.');
}
accept(item);
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
}
try {
await post(item, body, operationId);
replyConfirmed = true;
await markRead(item.notification_id);
} catch (error) {
if (!canQueue(error)) throw error;
return await admit(item, body, operationId, replyConfirmed);
}
accept(item);
return { accepted: true, delivery: 'posted', next: await next(item) };
} finally {
inFlight = null;
}
})();
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
-88
View File
@@ -1,88 +0,0 @@
{
"version": "0.0.1",
"commit": "8ff742c0fe0970d43339b8931054d63b1029591d",
"buildDate": "2026-07-12T12:56:54Z",
"artifacts": [
{
"path": "./.gitea/workflows/ci.yml",
"type": "file",
"bytes": 662,
"sha256": "6ea3766610118ac34d6e9e8996eb13771ba6428ac8df6969f1150c0e719ed915"
},\n {
"path": "./.gitea/workflows/release.yml",
"type": "file",
"bytes": 884,
"sha256": "647c8d8696fe77a04ab44a051816489a94d57caf33b8184a064fe8218193917c"
},\n {
"path": "./README.md",
"type": "file",
"bytes": 94,
"sha256": "00230b3e64de86031df67093173ae57dd38490b6ca43ce188f046e59756d86e2"
},\n {
"path": "./creative-deliverables/widget-board-5.html",
"type": "file",
"bytes": 799,
"sha256": "380da6f03fc3836ea2db2bbf15cd33a54991a48b67c22c6a4a02d58e1c82323a"
},\n {
"path": "./docs/ops-fundamentals.md",
"type": "file",
"bytes": 1123,
"sha256": "1d4cb0620a1ae2576ba0eac7cd3dc45f74771cc69277a9b39c30a953a2aa81ac"
},\n {
"path": "./frontend/index.html",
"type": "file",
"bytes": 19183,
"sha256": "5177c9fcf406ae13bd571f8d2679d075127af0eca0162f27a9021b209028304a"
},\n {
"path": "./frontend/static/index.html",
"type": "file",
"bytes": 15834,
"sha256": "d4a930b6b6604093481c667a16c8bf55e720bbbab9c0f8f15dcb515b6e3fdba1"
},\n {
"path": "./scripts/gitea_sync.py",
"type": "file",
"bytes": 2987,
"sha256": "15be3cf91384e4bc972d227d96868e92a3a0f50c97cd05fa85eb8f131377321b"
},\n {
"path": "./src/__init__.py",
"type": "file",
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},\n {
"path": "./src/gitea_proxy.py",
"type": "file",
"bytes": 914,
"sha256": "6af2ba285d5c070e9bbc95c81c5366d375c7d3293302a82bbf1812917e2191e4"
},\n {
"path": "./src/main.py",
"type": "file",
"bytes": 2771,
"sha256": "9f0de0f0ca911744bc6ac1796c84de4c11bb7f709f69a672e228117b322319c7"
},\n {
"path": "./src/models.py",
"type": "file",
"bytes": 850,
"sha256": "f0fdde5620006fb05cfe688607fe6815e424bf9a2b9798586a2c86cb7a62ba5a"
},\n {
"path": "./src/suggestion_engine.py",
"type": "file",
"bytes": 1774,
"sha256": "531d0187cfa348995e45ceacc510ac557d5ae4c7ef3d58fa56dc4b39e08ca3f8"
},\n {
"path": "./src/views.py",
"type": "file",
"bytes": 219,
"sha256": "b511ee830c4ae2c1564a81dfb52da9ded643b0612c3f46e0693fecc05d954b63"
},\n {
"path": "./tests/__init__.py",
"type": "file",
"bytes": 0,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},\n {
"path": "./tests/test_suggestion_engine.py",
"type": "file",
"bytes": 810,
"sha256": "d5c0ad9d51a9ec25556af190fe97a806bdfcee7d27e3f7666c584bbea2e3ef9c"
}
]
}
+3
View File
@@ -1,5 +1,8 @@
fastapi==0.133.1
httpx==0.28.1
pydantic==2.13.4
python-multipart==0.0.22
pytest==9.1.1
rjsmin==1.2.5
uvicorn==0.41.0
webauthn==3.0.0
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
from __future__ import annotations
import argparse
import gzip
import hashlib
import io
import json
import tarfile
from pathlib import Path
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
RUNTIME_FILES = ("README.md", "requirements.txt")
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def runtime_files(root: Path) -> list[Path]:
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
for name in RUNTIME_DIRECTORIES:
directory = root / name
if directory.is_dir():
files.extend(path for path in directory.rglob("*") if path.is_file())
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
return {
"schema_version": 1,
"commit": commit,
"files": {
path.relative_to(root).as_posix(): {
"sha256": sha256(path.read_bytes()),
"size": path.stat().st_size,
}
for path in files
},
}
def json_bytes(payload: dict) -> bytes:
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarfile.TarInfo:
info = tarfile.TarInfo(name)
info.size = size
info.mtime = epoch
info.mode = 0o755 if executable else 0o644
info.uid = info.gid = 0
info.uname = info.gname = "root"
return info
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
root = root.resolve()
files = runtime_files(root)
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
raise ValueError("release root is missing required runtime files")
embedded_manifest = manifest_for(root, files, commit)
archive_members: dict[str, tuple[bytes, bool]] = {
path.relative_to(root).as_posix(): (
path.read_bytes(),
bool(path.stat().st_mode & 0o111),
)
for path in files
}
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
output_dir.mkdir(parents=True, exist_ok=True)
stem = f"stackchain-dashboard-{commit[:12]}"
archive_path = output_dir / f"{stem}.tar.gz"
with archive_path.open("wb") as raw:
with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=epoch) as compressed:
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
for name in sorted(archive_members):
data, executable = archive_members[name]
archive.addfile(tar_info(name, len(data), epoch, executable), io.BytesIO(data))
archive_data = archive_path.read_bytes()
digest = sha256(archive_data)
public_manifest = {
**embedded_manifest,
"artifact": {
"name": archive_path.name,
"sha256": digest,
"size": len(archive_data),
},
}
manifest_path = output_dir / f"{stem}.manifest.json"
checksum_path = output_dir / f"{stem}.sha256"
manifest_path.write_bytes(json_bytes(public_manifest))
checksum_path.write_text(f"{digest} {archive_path.name}\n")
return archive_path, manifest_path, checksum_path
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
parser.add_argument("--commit", required=True)
parser.add_argument("--source-date-epoch", type=int, required=True)
return parser.parse_args()
def main() -> None:
args = parse_args()
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
print(path)
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Verify a Stackchain Dashboard release bundle before promotion."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import tarfile
from pathlib import Path, PurePosixPath
def one(directory: Path, pattern: str) -> Path:
matches = sorted(directory.glob(pattern))
if len(matches) != 1:
raise ValueError(f"expected exactly one {pattern} file, found {len(matches)}")
return matches[0]
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def verify(input_dir: Path, commit: str) -> None:
archive_path = one(input_dir, "*.tar.gz")
manifest_path = one(input_dir, "*.manifest.json")
checksum_path = one(input_dir, "*.sha256")
manifest = json.loads(manifest_path.read_text())
if manifest.get("commit") != commit:
raise ValueError("manifest commit does not match the tested commit")
artifact = manifest.get("artifact", {})
archive_data = archive_path.read_bytes()
archive_digest = digest(archive_data)
if artifact.get("name") != archive_path.name:
raise ValueError("manifest artifact name does not match the bundle")
if artifact.get("size") != len(archive_data) or artifact.get("sha256") != archive_digest:
raise ValueError("bundle does not match its manifest")
checksum_parts = checksum_path.read_text().strip().split()
if checksum_parts != [archive_digest, archive_path.name]:
raise ValueError("checksum file does not match the bundle")
expected_files = manifest.get("files")
if not isinstance(expected_files, dict):
raise ValueError("manifest files must be an object")
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
for member in members:
path = PurePosixPath(member.name)
if path.is_absolute() or ".." in path.parts or not member.isfile():
raise ValueError("bundle contains an unsafe member")
names = {member.name for member in members}
if names != set(expected_files) | {"release-manifest.json"}:
raise ValueError("bundle contents do not match the manifest")
embedded_file = archive.extractfile("release-manifest.json")
if embedded_file is None:
raise ValueError("bundle manifest is missing")
embedded = json.load(embedded_file)
if embedded.get("commit") != commit or embedded.get("files") != expected_files:
raise ValueError("embedded manifest does not match the public manifest")
for name, metadata in expected_files.items():
bundled = archive.extractfile(name)
if bundled is None:
raise ValueError(f"bundle member is missing: {name}")
data = bundled.read()
if metadata != {"sha256": digest(data), "size": len(data)}:
raise ValueError(f"bundle member failed verification: {name}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, required=True)
parser.add_argument("--commit", required=True)
return parser.parse_args()
def main() -> None:
args = parse_args()
try:
verify(args.input_dir, args.commit)
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
print(f"release verification failed: {error}", file=sys.stderr)
raise SystemExit(1) from error
print(f"verified release bundle for {args.commit}")
if __name__ == "__main__":
main()
+208
View File
@@ -0,0 +1,208 @@
"""Worker-shared Find Work catalog state."""
from __future__ import annotations
import json
import os
import secrets
import stat
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
class RefreshLeaseLost(RuntimeError):
pass
@dataclass(frozen=True)
class AvailableIssueSnapshotState:
items: list[dict] | None
created_at: float | None
retry_at: float | None
refreshing: bool
lease_expires_at: float | None
class AvailableIssueSnapshotStore:
def __init__(self, path, *, clock=None):
self.path = Path(path)
self.clock = clock or time.time
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.path.parent, stat.S_IRWXU)
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
items_json TEXT,
created_at REAL,
retry_at REAL
);
CREATE TABLE IF NOT EXISTS available_issue_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS available_issue_claim (
repository TEXT NOT NULL,
number INTEGER NOT NULL,
PRIMARY KEY (repository, number)
);
"""
)
connection.execute(
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def _connect(self):
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def try_acquire_refresh(self, *, lease_seconds: float) -> str | None:
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO available_issue_refresh_lease VALUES (1, ?, ?)",
(owner, now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT expires_at FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?", (now,)
).fetchone()
return AvailableIssueSnapshotState(
items=json.loads(row["items_json"]) if row["items_json"] else None,
created_at=row["created_at"],
retry_at=row["retry_at"],
refreshing=lease is not None,
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish(self, owner: str | None, *, items: list[dict]) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if (
owner is None
or lease is None
or lease["owner"] != owner
or lease["expires_at"] <= now
):
connection.rollback()
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
claimed = {
(row["repository"], row["number"])
for row in connection.execute(
"SELECT repository, number FROM available_issue_claim"
)
}
items = [
item for item in items
if (item.get("repository"), item.get("number")) not in claimed
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
"retry_at = NULL WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")), now),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
def remove_claimed(self, repository: str, number: int) -> AvailableIssueSnapshotState:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"INSERT OR IGNORE INTO available_issue_claim VALUES (?, ?)",
(repository, number),
)
row = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
items = json.loads(row["items_json"]) if row["items_json"] else None
if items is not None:
items = [
item for item in items
if item.get("repository") != repository or item.get("number") != number
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")),),
)
connection.commit()
return self.load()
def invalidate(self) -> AvailableIssueSnapshotState:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"UPDATE available_issue_snapshot SET items_json = NULL, "
"created_at = NULL, retry_at = NULL WHERE singleton = 1"
)
connection.execute("DELETE FROM available_issue_claim")
connection.commit()
return self.load()
def release_refresh(self, owner: str | None) -> None:
if owner is None:
return
with self._connect() as connection:
connection.execute(
"DELETE FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(self, owner: str | None, *, retry_at: float) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if (
owner is None
or lease is None
or lease["owner"] != owner
or lease["expires_at"] <= now
):
connection.rollback()
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
connection.execute(
"UPDATE available_issue_snapshot SET retry_at = ? WHERE singleton = 1",
(retry_at,),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+47
View File
@@ -0,0 +1,47 @@
from starlette.datastructures import Headers
from starlette.middleware.gzip import GZipResponder, IdentityResponder
from starlette.types import ASGIApp, Receive, Scope, Send
def _quality(accept_encoding: str, coding: str) -> float:
qualities: dict[str, float] = {}
for entry in accept_encoding.split(","):
parts = [part.strip() for part in entry.split(";")]
name = parts[0].lower()
if not name:
continue
quality = 1.0
for parameter in parts[1:]:
key, separator, value = parameter.partition("=")
if separator and key.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 0.0
qualities[name] = quality
return qualities.get(coding, qualities.get("*", 0.0))
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
path = scope.get("path", "").rsplit("/", 1)[-1]
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
else:
responder = IdentityResponder(self.app, self.minimum_size)
await responder(scope, receive, send)
+114 -8
View File
@@ -18,6 +18,8 @@ from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
INSECURE_LOCAL_MODE = "insecure-local"
@@ -28,6 +30,13 @@ class Session:
session_id: str
csrf: str
expires_at: int
idle_expires_at: int | None = None
@dataclass(frozen=True)
class SessionVerification:
session: Session | None
reason: str | None = None
def access_token() -> str:
@@ -89,7 +98,24 @@ def _session_store(now: int | None = None) -> SessionStore:
return SessionStore(database, clock=current)
def issue_session(now: int | None = None) -> tuple[str, Session]:
def idle_timeout_seconds() -> int:
return max(
1,
int(
os.getenv(
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
)
),
)
def issue_session(
now: int | None = None,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
session = Session(
@@ -104,17 +130,24 @@ def issue_session(now: int | None = None) -> tuple[str, Session]:
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(session.session_id, session.expires_at)
_session_store(now).activate(
session.session_id,
session.expires_at,
device_label=device_label,
management_id=management_id,
)
return f"{encoded}.{signature}", session
def verify_session(value: str | None, now: int | None = None) -> Session | None:
def verify_session_with_reason(
value: str | None, now: int | None = None
) -> SessionVerification:
if not value or "." not in value or not enabled():
return None
return SessionVerification(None)
encoded, supplied_signature = value.rsplit(".", 1)
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
if not hmac.compare_digest(supplied_signature, expected):
return None
return SessionVerification(None)
try:
payload = json.loads(_decode(encoded))
session = Session(
@@ -123,7 +156,7 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
expires_at=int(payload["exp"]),
)
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return None
return SessionVerification(None)
current = int(time.time() if now is None else now)
if (
session.expires_at <= current
@@ -132,18 +165,91 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
or not isinstance(session.csrf, str)
or not session.csrf
):
return None
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
return SessionVerification(None)
status = _session_store(now).status(
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
if status == "idle":
return SessionVerification(None, "session_idle")
if status != "active":
return SessionVerification(None, "session_revoked")
return SessionVerification(
Session(
session_id=session.session_id,
csrf=session.csrf,
expires_at=session.expires_at,
idle_expires_at=getattr(status, "idle_expires_at", None),
)
)
def verify_session(value: str | None, now: int | None = None) -> Session | None:
return verify_session_with_reason(value, now).session
def revoke_session(session: Session) -> None:
_session_store().revoke(session.session_id)
async def revoke_all_sessions() -> None:
await asyncio.to_thread(_session_store().revoke_all)
async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def session_management_id(session: Session) -> str:
return await asyncio.to_thread(_session_store().management_id, session.session_id)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def revoke_managed_session(management_id: str) -> bool:
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
async def issue_step_up(session: Session, *, action: str, target: str) -> str:
return await asyncio.to_thread(
_session_store().mint_step_up,
session.session_id,
action=action,
target=target,
ttl_seconds=STEP_UP_TTL_SECONDS,
)
async def consume_step_up(
grant: str, session: Session, *, action: str, target: str
) -> bool:
return await asyncio.to_thread(
_session_store().consume_step_up,
grant,
session.session_id,
action=action,
target=target,
)
async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
async def request_session_verification(request: Request) -> SessionVerification:
return await asyncio.to_thread(
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
)
def cookie_path(request: Request) -> str:
root_path = request.scope.get("root_path", "").rstrip("/")
return root_path or "/"
+151
View File
@@ -0,0 +1,151 @@
"""Build the content-addressed browser shell served by the dashboard."""
from __future__ import annotations
import gzip
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"issue-capture": ("static/create-issue-sheet.js",),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
}
CACHE_DECLARATION = re.compile(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
)
@dataclass(frozen=True)
class RuntimeBundle:
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
@dataclass(frozen=True)
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
shell_digest: str
page_sources: tuple[str, ...]
feature_bundles: dict[str, RuntimeBundle]
service_worker_source: str
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
chunks = []
for source in sources:
path = frontend_dir / source.removeprefix("static/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
source = b"".join(chunks)
revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode()
return minified + f';"source-sha256:{revision}";'.encode()
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
content = _bundle(frontend_dir, sources)
digest = hashlib.sha256(content).hexdigest()[:16]
return RuntimeBundle(
runtime_bytes=content,
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
runtime_digest=digest,
runtime_name=f"{prefix}-{digest}.js",
)
def build_frontend(frontend_dir: Path) -> FrontendBuild:
"""Return one internally consistent HTML, runtime, and offline-worker build."""
source_html = (frontend_dir / "index.html").read_text()
sources = tuple(SCRIPT_TAG.findall(source_html))
if not sources:
raise ValueError("dashboard entry document has no page scripts")
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
core_sources = tuple(source for source in sources if source not in feature_source_names)
core = _runtime(frontend_dir, core_sources, "runtime")
feature_bundles = {
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
for name, feature_sources in FEATURE_SOURCES.items()
}
dashboard_html = SCRIPT_TAG.sub("", source_html)
feature_metadata = "\n".join(
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>")
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
)
worker = (frontend_dir / "service-worker.js").read_text()
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
)
shell_hasher = hashlib.sha256()
shell_inputs = (
("dashboard.html", dashboard_html.encode()),
(core.runtime_name, core.runtime_bytes),
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
(
"static/icons/stackchain-192.png",
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
),
(
"static/icons/stackchain-512.png",
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
),
(
WORKER_RUNTIME_SOURCE,
(
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
).read_bytes(),
),
("service-worker.js", worker.encode()),
)
for name, content in shell_inputs:
shell_hasher.update(len(name).to_bytes(4, "big"))
shell_hasher.update(name.encode())
shell_hasher.update(len(content).to_bytes(8, "big"))
shell_hasher.update(content)
shell_digest = shell_hasher.hexdigest()[:16]
worker = worker.replace(
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
1,
)
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=core.runtime_bytes,
runtime_gzip_bytes=core.runtime_gzip_bytes,
runtime_digest=core.runtime_digest,
runtime_name=core.runtime_name,
shell_digest=shell_digest,
page_sources=sources,
feature_bundles=feature_bundles,
service_worker_source=worker,
)
+554 -5
View File
@@ -4,7 +4,7 @@ import re
import shlex
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import urlsplit
from urllib.parse import urljoin, urlsplit
import httpx
@@ -133,6 +133,14 @@ class WorkItems(list[dict]):
self.pagination = pagination
class RepositoryItems(list[dict]):
"""A list-compatible repository page carrying truthful pagination metadata."""
def __init__(self, items: list[dict], pagination: dict):
super().__init__(items)
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
@@ -161,6 +169,10 @@ class IssueEditConflictError(ValueError):
"""Raised when an issue changed after the editor loaded it."""
class IssueDependencyInvalidError(ValueError):
"""Raised when a requested blocker relationship is not valid."""
def _auth() -> dict[str, str]:
headers: dict[str, str] = {"Accept": "application/json"}
if GITEA_TOKEN:
@@ -261,8 +273,63 @@ async def current_user() -> dict:
return await fetch("user")
async def repos() -> list[dict]:
return await fetch("user/repos?limit=50")
async def repo_page(page: int = 1, limit: int = 50) -> dict:
"""Load one bounded page of repositories available to the current user."""
response = await _get_client().get(
"/api/v1/user/repos",
headers=_auth(),
params={"page": page, "limit": limit},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea repository response was not a list")
items = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
async def repos() -> RepositoryItems:
result = await repo_page()
return RepositoryItems(
result["items"],
{key: result[key] for key in ("page", "total", "has_more")},
)
async def search_repositories(query: str, limit: int = 20) -> list[dict]:
"""Search repositories visible to the authenticated Gitea user."""
response = await _get_client().get(
"/api/v1/repos/search",
headers=_auth(),
params={"q": query.strip(), "limit": limit, "page": 1},
)
response.raise_for_status()
payload = response.json()
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
raise ValueError("Gitea repository search response was not a list")
return [item for item in items if isinstance(item, dict)]
async def repository_access(repository: str) -> dict | None:
"""Return a repository only when the authenticated user can access it."""
response = await _get_client().get(
f"/api/v1/repos/{repository}", headers=_auth()
)
if response.status_code == 404:
return None
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else None
WORK_SEARCHES = {
@@ -537,6 +604,51 @@ def _safe_web_url(value: Any) -> str:
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
def _safe_gitea_web_url(value: Any) -> str:
if not isinstance(value, str) or not value.strip():
return ""
resolved = urljoin(f"{GITEA_URL}/", value.strip())
parsed = urlsplit(resolved)
configured = urlsplit(GITEA_URL)
base_path = configured.path.rstrip("/")
if (
parsed.scheme not in {"http", "https"}
or parsed.scheme != configured.scheme
or parsed.netloc != configured.netloc
or (base_path and parsed.path != base_path and not parsed.path.startswith(f"{base_path}/"))
):
return ""
return resolved
def _normalize_commit_checks(status: Any) -> list[dict]:
entries = status.get("statuses") if isinstance(status, dict) else None
if not isinstance(entries, list):
return []
rank = {"error": 0, "failure": 0, "pending": 1, "warning": 2, "success": 3}
checks = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
continue
name = entry.get("context")
if not isinstance(name, str) or not name.strip():
continue
state = entry.get("status", entry.get("state", "unknown"))
state = state.lower() if isinstance(state, str) else "unknown"
if state not in rank:
state = "unknown"
description = entry.get("description")
checks.append({
"name": name.strip()[:120],
"state": state,
"description": description.strip()[:240] if isinstance(description, str) else "",
"url": _safe_gitea_web_url(entry.get("target_url")),
"_index": index,
})
checks.sort(key=lambda check: (rank.get(check["state"], 2), check["_index"]))
return [{key: value for key, value in check.items() if key != "_index"} for check in checks[:20]]
def _normalize_notifications(threads: Any) -> list[dict]:
if not isinstance(threads, list):
raise ValueError("Gitea notification response was not a list")
@@ -623,6 +735,60 @@ async def mark_notification_read(thread_id: int) -> None:
response.raise_for_status()
async def acknowledge_notification(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
repository_name = repository.get("full_name")
subject_path = _gitea_api_path(subject.get("url"))
comment_path = _gitea_api_path(subject.get("latest_comment_url"))
subject_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
comment_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
)
if (
not subject_match
or not comment_match
or subject_match.group(1) != repository_name
or comment_match.group(1) != repository_name
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification has no supported latest comment")
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Authenticated Gitea user is unavailable")
reaction_path = f"/api/v1/{comment_path}/reactions"
response = await _get_client().get(reaction_path, headers=_auth())
response.raise_for_status()
reactions = response.json()
existing = any(
isinstance(reaction, dict)
and reaction.get("content") == "+1"
and isinstance(reaction.get("user"), dict)
and reaction["user"].get("login") == login
for reaction in (reactions if isinstance(reactions, list) else [])
)
if not existing:
response = await _get_client().post(
reaction_path, headers=_auth(), json={"content": "+1"}
)
response.raise_for_status()
await mark_notification_read(thread_id)
return {
"id": thread_id,
"reaction": "existing" if existing else "created",
"status": "read",
}
def _gitea_api_path(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -651,6 +817,9 @@ async def notification_detail(thread_id: int) -> dict:
conversation_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path
)
comment_match = re.fullmatch(
r"repos/([^/]+/[^/]+)/issues/comments/(\d+)", comment_path
)
repository_name = repository.get("full_name")
supported_conversation = (
conversation_match
@@ -680,6 +849,21 @@ async def notification_detail(thread_id: int) -> dict:
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
subject.get("latest_comment_html_url")
)
assignee_values = subject_detail.get("assignees")
assignees = [
value.get("login")
for value in (assignee_values if isinstance(assignee_values, list) else [])
if isinstance(value, dict) and isinstance(value.get("login"), str)
]
issue = {
"number": subject_detail.get("number"),
"assignees": assignees,
"claimable": (
subject.get("type") == "Issue"
and subject_detail.get("state", subject.get("state")) == "open"
and not assignees
),
} if supported_conversation and subject.get("type") == "Issue" else None
return {
"id": thread_id,
"repository": repository.get("full_name", "")
@@ -710,6 +894,12 @@ async def notification_detail(thread_id: int) -> dict:
else "",
"url": latest_url,
},
"issue": issue,
"acknowledge_supported": bool(
supported_conversation
and comment_match
and comment_match.group(1) == repository_name
),
"conversation": conversation,
}
@@ -768,6 +958,43 @@ async def reply_to_notification(thread_id: int, body: str) -> dict:
return _normalize_issue_comment(comment)
async def upload_notification_attachment(
thread_id: int, filename: str, content_type: str, content: bytes
) -> dict:
"""Upload to the exact issue or pull identified by a trusted notification."""
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject = thread.get("subject")
if not isinstance(repository, dict) or not isinstance(subject, dict):
raise ValueError("Notification does not identify a conversation")
subject_path = _gitea_api_path(subject.get("url"))
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
if (
not match
or match.group(1) != repository.get("full_name")
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
# Gitea stores pull-request assets on its shared issue asset endpoint.
response = await _get_client().post(
f"/api/v1/repos/{match.group(1)}/issues/{match.group(3)}/assets",
headers=_auth(), params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def close_issue(repository: str, number: int) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
@@ -814,6 +1041,60 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
return _normalize_issue_comment(comment)
async def upload_assigned_issue_attachment(
repository: str,
number: int,
filename: str,
content_type: str,
content: bytes,
) -> dict:
if not await is_assigned_issue(repository, number):
raise IssueNotAvailableError("Assigned issue not found")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/assets",
headers=_auth(),
params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def upload_assigned_pull_attachment(
repository: str,
number: int,
filename: str,
content_type: str,
content: bytes,
) -> dict:
if not await is_assigned_pull(repository, number):
raise IssueNotAvailableError("Assigned pull request not found")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/assets",
headers=_auth(),
params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def repo_labels(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/labels",
@@ -1038,6 +1319,124 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
}
async def issue_handoff_candidates(repository: str) -> list[dict]:
user, response = await asyncio.gather(
current_user(),
_get_client().get(
f"/api/v1/repos/{repository}/assignees", headers=_auth()
),
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea assignees response was not a list")
current_login = user.get("login") if isinstance(user, dict) else None
candidates = []
for item in payload:
if not isinstance(item, dict):
continue
login = item.get("login")
if not isinstance(login, str) or not login or login == current_login:
continue
full_name = item.get("full_name")
candidates.append({
"login": login,
"name": full_name if isinstance(full_name, str) and full_name else login,
})
return candidates
async def mention_candidates(
repository: str, query: str, *, limit: int = 8
) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/assignees", headers=_auth()
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea assignees response was not a list")
needle = query.casefold()
matches = []
for item in payload:
if not isinstance(item, dict):
continue
login = item.get("login")
if (
not isinstance(login, str)
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
):
continue
full_name = item.get("full_name")
name = full_name if isinstance(full_name, str) and full_name else login
login_match = needle in login.casefold()
name_match = needle in name.casefold()
if login_match or name_match:
matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), {
"login": login,
"name": name,
}))
matches.sort(key=lambda item: (item[0], item[1]))
return [item[2] for item in matches[:limit]]
async def handoff_assigned_issue(
repository: str, number: int, recipient: str
) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("Issue is not assigned to the current user")
eligible = {
item["login"] for item in await issue_handoff_candidates(repository)
}
if recipient not in eligible:
raise IssueNotAvailableError("Handoff recipient is not eligible")
assignees_value = issue.get("assignees")
assignees = assignees_value if isinstance(assignees_value, list) else []
desired = [
item["login"] for item in assignees
if isinstance(item, dict)
and isinstance(item.get("login"), str)
and item["login"] != login
]
if recipient not in desired:
desired.append(recipient)
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": desired},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
item["login"] for item in confirmed_value
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login in confirmed_assignees
or recipient not in confirmed_assignees
or set(confirmed_assignees) != set(desired)
):
raise ValueError("Gitea did not confirm issue handoff")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"recipient": recipient,
}
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
@@ -1195,9 +1594,19 @@ async def issue_conversation_page(
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
async def load_dependencies() -> tuple[bool, list[dict]]:
try:
return True, await issue_dependencies(repository, number)
except Exception:
return False, []
issue, conversation, dependency_result = await asyncio.gather(
fetch(base),
issue_conversation_page(repository, number),
load_dependencies(),
)
dependencies_available, dependencies = dependency_result
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
labels_value = issue.get("labels")
@@ -1229,11 +1638,110 @@ async def issue_detail(repository: str, number: int) -> dict:
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"dependencies_available": dependencies_available,
"dependencies": dependencies,
"comments": normalized_comments,
"conversation": conversation,
}
async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]:
"""Return bounded open prerequisites for an issue."""
response = await _get_client().get(
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
headers=_auth(),
params={"limit": limit},
)
response.raise_for_status()
value = response.json()
items = value if isinstance(value, list) else []
dependencies = []
for item in items[:limit]:
if not isinstance(item, dict) or item.get("state") != "open":
continue
repo_value = item.get("repository")
repo = repo_value if isinstance(repo_value, dict) else {}
dependency_repository = repo.get("full_name")
dependency_number = item.get("number")
if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int):
continue
dependencies.append({
"repository": dependency_repository,
"number": dependency_number,
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(item.get("html_url")),
})
return dependencies
async def mutate_assigned_issue_dependency(
repository: str,
number: int,
blocker_repository: str,
blocker_number: int,
remove: bool = False,
) -> dict:
"""Add or remove one prerequisite and return the canonical open dependency set."""
if (repository, number) == (blocker_repository, blocker_number):
raise IssueDependencyInvalidError("an issue cannot block itself")
login, source = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
if (
source.get("state") != "open"
or isinstance(source.get("pull_request"), dict)
or not _login_in_users(login, source.get("assignees"))
):
raise IssueNotAvailableError("assigned issue not found")
current = await issue_dependencies(repository, number)
currently_present = any(
item["repository"] == blocker_repository and item["number"] == blocker_number
for item in current
)
if currently_present != remove:
return {
"repository": repository,
"number": number,
"dependencies_available": True,
"dependencies": current,
}
if not remove:
candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}")
if (
not isinstance(candidate, dict)
or candidate.get("number") != blocker_number
or candidate.get("state") != "open"
or isinstance(candidate.get("pull_request"), dict)
):
raise IssueDependencyInvalidError("blocker must be an accessible open issue")
owner, repo = blocker_repository.split("/", 1)
response = await _get_client().request(
"DELETE" if remove else "POST",
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
headers=_auth(),
json={"owner": owner, "repo": repo, "index": blocker_number},
)
response.raise_for_status()
confirmed = await issue_dependencies(repository, number)
present = any(
item["repository"] == blocker_repository and item["number"] == blocker_number
for item in confirmed
)
if present == remove:
raise ValueError("Gitea did not confirm the dependency change")
return {
"repository": repository,
"number": number,
"dependencies_available": True,
"dependencies": confirmed,
}
async def update_assigned_issue(
repository: str,
number: int,
@@ -1448,6 +1956,7 @@ async def pull_completion_review(repository: str, number: int) -> dict:
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"files": [
{
"filename": item.get("filename", ""),
@@ -1470,6 +1979,45 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
async def pull_check_status(repository: str, number: int) -> dict:
"""Load only mutable pull and CI state, without immutable review data."""
base = f"repos/{repository}/pulls/{number}"
pull = await fetch(base)
if not isinstance(pull, dict):
raise ValueError("Gitea pull request response was not an object")
head_value = pull.get("head")
head: dict = head_value if isinstance(head_value, dict) else {}
sha_value = head.get("sha")
sha = sha_value if isinstance(sha_value, str) else ""
status = await fetch(f"repos/{repository}/commits/{sha}/status")
return {
"repository": repository,
"number": number,
"head_sha": sha,
"state": pull.get("state") if isinstance(pull.get("state"), str) else "",
"draft": pull.get("draft") is True,
"mergeable": pull.get("mergeable") is True,
"merged": pull.get("merged") is True,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
}
async def is_pull_merged_at_head(
repository: str, number: int, expected_head_sha: str
) -> bool:
pull = await fetch(f"repos/{repository}/pulls/{number}")
if not isinstance(pull, dict):
return False
head_value = pull.get("head")
head = head_value if isinstance(head_value, dict) else {}
return (
pull.get("merged") is True
and pull.get("state") == "closed"
and head.get("sha") == expected_head_sha
)
async def merge_assigned_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
@@ -1565,6 +2113,7 @@ async def pull_review_detail(repository: str, number: int) -> dict:
"author": user.get("login", ""),
"head_sha": sha,
"ci_state": status.get("state", "unknown") if isinstance(status, dict) else "unknown",
"checks": _normalize_commit_checks(status),
"files": normalized_files,
"reviews": normalized_reviews,
}
+6 -2
View File
@@ -78,7 +78,7 @@ class IdempotencyLedger:
(now - self.ttl_seconds,),
)
row = connection.execute(
"SELECT fingerprint, status, response_json FROM idempotency_operations "
"SELECT fingerprint, status, response_json, created_at FROM idempotency_operations "
"WHERE key = ?",
(key,),
).fetchone()
@@ -87,9 +87,13 @@ class IdempotencyLedger:
return Reservation("conflict")
if row[1] == "completed":
return Reservation("completed", json.loads(row[2]))
if row[3] <= now - self.ttl_seconds:
return Reservation("uncertain")
return Reservation("pending")
count = connection.execute(
"SELECT COUNT(*) FROM idempotency_operations"
"SELECT COUNT(*) FROM idempotency_operations "
"WHERE status = 'completed' OR created_at > ?",
(now - self.ttl_seconds,),
).fetchone()[0]
if count >= self.max_entries:
completed = connection.execute(
+231
View File
@@ -0,0 +1,231 @@
"""Durable, account-scoped Later deferrals."""
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
class LaterStore:
def __init__(
self,
path: str | Path,
*,
timeout: float = 1.0,
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
clock=time.time,
):
self.path = Path(path)
self.timeout = timeout
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
def _connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS later_plans (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
records TEXT NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS later_operations (
login TEXT NOT NULL,
operation_id TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (login, operation_id)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS later_item_revisions (
login TEXT NOT NULL,
item_id TEXT NOT NULL,
revision INTEGER NOT NULL,
PRIMARY KEY (login, item_id)
)
"""
)
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
if "created_at" not in columns:
connection.execute("ALTER TABLE later_operations ADD COLUMN created_at REAL")
connection.execute(
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
(self.clock(),),
)
connection.commit()
return connection
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
now = self.clock()
connection.execute(
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
(login, operation_id, now),
)
connection.execute(
"DELETE FROM later_operations WHERE login = ? AND created_at < ?",
(login, now - self.operation_retention_seconds),
)
connection.execute(
"DELETE FROM later_operations WHERE login = ? AND rowid NOT IN "
"(SELECT rowid FROM later_operations WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
(login, login, self.operation_limit),
)
@staticmethod
def _normalize_login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
if row is None:
return {"revision": 0, "records": {}}
return {"revision": int(row[0]), "records": json.loads(row[1])}
@staticmethod
def _validate_wake_at(wake_at: str | None) -> str:
if not wake_at:
raise ValueError("wake_at is required for defer")
try:
datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("wake_at must be an ISO timestamp") from error
return wake_at
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
def apply(
self,
login: str,
operation_id: str,
action: str,
item_id: str,
*,
wake_at: str | None = None,
base_revision: int | None = None,
) -> dict:
result = self.apply_batch(login, [{
"operation_id": operation_id,
"action": action,
"item_id": item_id,
"wake_at": wake_at,
"base_revision": base_revision,
}])
return {"revision": result["revision"], "records": result["records"]}
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply ordered deferrals with one SQLite write transaction."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
records = dict(snapshot["records"])
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
rejected: list[dict[str, str]] = []
# Existing databases predate per-item revisions. Conservatively mark
# active deferrals as changed at the latest known plan revision.
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())
batch_start_item_revisions = dict(item_revisions)
for operation in operations:
operation_id = operation.get("operation_id", "")
action = operation.get("action", "")
item_id = operation.get("item_id", "")
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"defer", "restore"}:
raise ValueError("unsupported Later action")
wake_at = operation.get("wake_at")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
duplicates.append(operation_id)
continue
base_revision = operation.get("base_revision")
if base_revision is None:
base_revision = snapshot["revision"]
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
raise ValueError("base_revision must be a non-negative integer")
if batch_start_item_revisions.get(item_id, 0) > base_revision:
self._record_operation(connection, login, operation_id)
rejected.append({
"operation_id": operation_id,
"reason": "stale_intent",
})
continue
before = records.get(item_id)
if action == "defer":
records[item_id] = wake_at
changed = before != wake_at
else:
changed = item_id in records
records.pop(item_id, None)
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),
)
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
if row is None:
connection.execute(
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif accepted:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
)
return {
"revision": revision,
"records": records,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
}
+332
View File
@@ -0,0 +1,332 @@
"""Process-shared live snapshot state and refresh coordination."""
from __future__ import annotations
import json
import os
import secrets
import sqlite3
import stat
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
SECTIONS = ("context", "events", "notifications")
class RefreshLeaseLost(RuntimeError):
"""The refresh is no longer authorized to publish shared state."""
@dataclass(frozen=True)
class LiveSnapshotState:
value: dict | None
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
@dataclass(frozen=True)
class LiveSnapshotMetadata:
"""Shared snapshot coordination fields without the potentially large value."""
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
class LiveSnapshotStore:
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
def __init__(
self,
path: str | os.PathLike[str],
*,
clock: Callable[[], float] | None = None,
):
self.path = Path(path)
self.clock = clock or time.time
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def _initialize(self) -> None:
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
os.chmod(self.path.parent, stat.S_IRWXU)
except OSError:
pass
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS live_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
value_json TEXT,
created_at_json TEXT NOT NULL,
failure_count_json TEXT NOT NULL,
retry_at_json TEXT NOT NULL,
revisions_json TEXT NOT NULL,
generation TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS live_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
sections_json TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS live_read_notification (
notification_id INTEGER PRIMARY KEY
);
"""
)
connection.execute(
"""INSERT OR IGNORE INTO live_snapshot VALUES
(1, NULL, ?, ?, ?, ?, ?)""",
(
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
secrets.token_hex(8),
),
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def try_acquire_refresh(
self, sections: Iterable[str], *, lease_seconds: float
) -> str | None:
requested = sorted(set(sections))
if not requested or any(section not in SECTIONS for section in requested):
raise ValueError("refresh lease requires known sections")
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO live_refresh_lease VALUES (1, ?, ?, ?)",
(owner, json.dumps(requested, separators=(",", ":")), now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> LiveSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM live_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotState(
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def load_metadata(self) -> LiveSnapshotMetadata:
"""Load freshness and coordination state without reading the snapshot value."""
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"""SELECT created_at_json, failure_count_json, retry_at_json,
revisions_json, generation FROM live_snapshot WHERE singleton = 1"""
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotMetadata(
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish_refresh(
self,
owner: str,
*,
value: dict,
created_at: dict[str, float | None],
failure_count: dict[str, int],
retry_at: dict[str, float | None],
changed_sections: Iterable[str],
) -> LiveSnapshotState:
changed = set(changed_sections)
if changed - set(SECTIONS):
raise ValueError("publication contains unknown sections")
if any(set(mapping) != set(SECTIONS) for mapping in (created_at, failure_count, retry_at)):
raise ValueError("publication metadata must contain every section")
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
row = connection.execute(
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
revisions = json.loads(row["revisions_json"])
notifications = value.get("notifications")
if isinstance(notifications, list):
returned_ids = {
item.get("id") for item in notifications if isinstance(item, dict)
}
read_ids = {
item["notification_id"]
for item in connection.execute(
"SELECT notification_id FROM live_read_notification"
)
}
value = dict(value)
value["notifications"] = [
item for item in notifications
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if returned_ids:
placeholders = ",".join("?" for _ in returned_ids)
connection.execute(
f"DELETE FROM live_read_notification WHERE notification_id NOT IN ({placeholders})",
tuple(returned_ids),
)
else:
connection.execute("DELETE FROM live_read_notification")
for section in changed:
revisions[section] += 1
connection.execute(
"""UPDATE live_snapshot SET value_json = ?, created_at_json = ?,
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
WHERE singleton = 1""",
(
json.dumps(value, separators=(",", ":")),
json.dumps(created_at, separators=(",", ":")),
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?", (owner,)
)
connection.commit()
return self.load()
def remove_notifications(self, notification_ids: Iterable[int]) -> LiveSnapshotState:
"""Filter confirmed reads from every worker and subsequent stale refreshes."""
read_ids = set(notification_ids)
if not read_ids:
return self.load()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.executemany(
"INSERT OR IGNORE INTO live_read_notification VALUES (?)",
((notification_id,) for notification_id in read_ids),
)
row = connection.execute(
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
value = json.loads(row["value_json"]) if row["value_json"] else None
revisions = json.loads(row["revisions_json"])
if value is not None and isinstance(value.get("notifications"), list):
previous = value["notifications"]
retained = [
item
for item in previous
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if retained != previous:
value["notifications"] = retained
revisions["notifications"] += 1
connection.execute(
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
(
json.dumps(value, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.commit()
return self.load()
def remove_notification(self, notification_id: int) -> LiveSnapshotState:
"""Filter one confirmed read from every worker and subsequent stale refresh."""
return self.remove_notifications([notification_id])
def release_refresh(self, owner: str) -> None:
"""Relinquish a lease that became unnecessary after a coherent recheck."""
with self._connect() as connection:
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(
self,
owner: str,
*,
failure_count: dict[str, int],
retry_at: dict[str, float | None],
) -> LiveSnapshotState:
"""Publish backoff metadata and relinquish a failed refresh atomically."""
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
connection.execute(
"""UPDATE live_snapshot SET failure_count_json = ?, retry_at_json = ?
WHERE singleton = 1""",
(
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
),
)
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+146 -1
View File
@@ -49,6 +49,9 @@ class LoginAttemptStore:
window_seconds: int,
max_entries: int = 10_000,
lock_timeout_seconds: float = 0.1,
alert_bucket_seconds: int = 60 * 60,
alert_retention_seconds: int = 30 * 24 * 60 * 60,
max_alert_buckets: int = 720,
) -> None:
self.path = Path(path)
self.clock = clock
@@ -56,6 +59,9 @@ class LoginAttemptStore:
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
self.lock_timeout_seconds = lock_timeout_seconds
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
self.alert_retention_seconds = max(1, alert_retention_seconds)
self.max_alert_buckets = max(1, max_alert_buckets)
@staticmethod
def _digest(source: str) -> str:
@@ -74,6 +80,30 @@ class LoginAttemptStore:
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_admissions (
bucket TEXT NOT NULL,
source_hash TEXT NOT NULL,
attempts INTEGER NOT NULL,
window_started_at REAL NOT NULL,
PRIMARY KEY (bucket, source_hash)
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS login_alerts (
method TEXT NOT NULL,
bucket_started_at INTEGER NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
blocked_count INTEGER NOT NULL DEFAULT 0,
first_at INTEGER NOT NULL,
last_at INTEGER NOT NULL,
PRIMARY KEY (method, bucket_started_at)
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
@@ -97,7 +127,81 @@ class LoginAttemptStore:
remaining = row[1] + self.window_seconds - now
return max(0, math.ceil(remaining))
def record_failure(self, source: str) -> None:
def admit(self, bucket: str, source: str, *, limit: int) -> int:
"""Consume one fixed-window admission or return seconds until retry."""
now = self.clock()
source_hash = self._digest(source)
limit = max(1, limit)
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM login_admissions WHERE window_started_at + ? <= ?",
(self.window_seconds, now),
)
row = connection.execute(
"SELECT attempts, window_started_at FROM login_admissions "
"WHERE bucket = ? AND source_hash = ?",
(bucket, source_hash),
).fetchone()
if row is not None and row[0] >= limit:
return max(1, math.ceil(row[1] + self.window_seconds - now))
if row is None:
connection.execute(
"INSERT INTO login_admissions VALUES (?, ?, 1, ?)",
(bucket, source_hash, now),
)
else:
connection.execute(
"UPDATE login_admissions SET attempts = attempts + 1 "
"WHERE bucket = ? AND source_hash = ?",
(bucket, source_hash),
)
connection.execute(
"""
DELETE FROM login_admissions
WHERE (bucket, source_hash) NOT IN (
SELECT bucket, source_hash FROM login_admissions
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_entries,),
)
return 0
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def _record_alert(
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
) -> None:
safe_method = method if method in {"token", "passkey"} else "unknown"
occurred_at = int(now)
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
failed = 0 if blocked else 1
blocked_count = 1 if blocked else 0
connection.execute(
"""
INSERT INTO login_alerts(
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
failed_count = failed_count + excluded.failed_count,
blocked_count = blocked_count + excluded.blocked_count,
first_at = MIN(first_at, excluded.first_at),
last_at = MAX(last_at, excluded.last_at)
""",
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
)
connection.execute(
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
"(SELECT method, bucket_started_at FROM login_alerts "
"ORDER BY bucket_started_at DESC LIMIT ?)",
(self.max_alert_buckets,),
)
def record_failure(self, source: str, *, method: str = "token") -> None:
now = self.clock()
source_hash = self._digest(source)
try:
@@ -131,11 +235,52 @@ class LoginAttemptStore:
""",
(self.max_entries,),
)
self._record_alert(connection, method, blocked=False, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def record_blocked(self, *, method: str = "token") -> None:
now = self.clock()
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
self._record_alert(connection, method, blocked=True, now=now)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
bounded_limit = min(100, max(1, limit))
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM login_alerts WHERE last_at < ?",
(now - self.alert_retention_seconds,),
)
rows = connection.execute(
"SELECT method, failed_count, blocked_count, first_at, last_at "
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
(bounded_limit,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
return [
{
"method": row[0],
"failed_count": row[1],
"blocked_count": row[2],
"first_at": row[3],
"last_at": row[4],
}
for row in rows
]
def clear(self, source: str) -> None:
try:
with self._connect() as connection:
+2333 -121
View File
File diff suppressed because it is too large. Load diff
+325
View File
@@ -0,0 +1,325 @@
"""Durable, one-time WebAuthn challenges and device-bound passkey credentials."""
import hashlib
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from src.session_store import SessionStoreError
@dataclass(frozen=True)
class StoredPasskey:
credential_id: bytes
public_key: bytes
sign_count: int
device_label: str
management_id: str
created_at: int
class PasskeyStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_challenges: int = 10_000,
max_challenges_per_source: int = 10,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_challenges = max(1, max_challenges)
self.max_challenges_per_source = max(1, max_challenges_per_source)
@staticmethod
def _digest(value: bytes | str) -> str:
raw = value if isinstance(value, bytes) else value.encode()
return hashlib.sha256(raw).hexdigest()
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=0.1)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_credentials (
credential_id BLOB PRIMARY KEY,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_challenges (
challenge_hash TEXT PRIMARY KEY,
session_hash TEXT,
source_hash TEXT,
purpose TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
columns = {
row[1]
for row in connection.execute("PRAGMA table_info(passkey_challenges)")
}
if "source_hash" not in columns:
connection.execute(
"ALTER TABLE passkey_challenges ADD COLUMN source_hash TEXT"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def issue_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
source: str | None = None,
ttl_seconds: int = 120,
) -> None:
now = int(self.clock())
source_hash = self._digest(source or session_id or f"{purpose}:{action}:{target}")
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
connection.execute(
"INSERT INTO passkey_challenges("
"challenge_hash, session_hash, source_hash, purpose, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?, ?, ?)",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
source_hash,
purpose,
action,
target,
now + max(1, ttl_seconds),
),
)
connection.execute(
"""
DELETE FROM passkey_challenges
WHERE source_hash = ? AND challenge_hash NOT IN (
SELECT challenge_hash FROM passkey_challenges
WHERE source_hash = ?
ORDER BY expires_at DESC, rowid DESC LIMIT ?
)
""",
(source_hash, source_hash, self.max_challenges_per_source),
)
connection.execute(
"""
DELETE FROM passkey_challenges
WHERE challenge_hash NOT IN (
SELECT challenge_hash FROM passkey_challenges
ORDER BY expires_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_challenges,),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def consume_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
cursor = connection.execute(
"DELETE FROM passkey_challenges WHERE challenge_hash = ? "
"AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? "
"AND expires_at > ?",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
purpose,
action,
target,
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def register(
self,
*,
credential_id: bytes,
public_key: bytes,
sign_count: int,
device_label: str,
management_id: str,
) -> None:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
sign_count,
device_label,
management_id,
int(self.clock()),
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def all(self) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials ORDER BY created_at DESC"
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return [StoredPasskey(*row) for row in rows]
def get(self, credential_id: bytes) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def get_management_id(self, management_id: str) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE passkey_credentials SET sign_count = ? "
"WHERE credential_id = ? AND sign_count <= ?",
(new_sign_count, credential_id, new_sign_count),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_management_id(self, management_id: str) -> bool:
try:
with self._connect() as connection:
cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_access(
self, management_id: str, *, preserve_session: bool
) -> tuple[bool, bool]:
"""Atomically remove one passkey and, when requested, its active session."""
try:
with self._connect() as connection:
credential = connection.execute(
"SELECT 1 FROM passkey_credentials WHERE management_id = ?",
(management_id,),
).fetchone()
if credential is None:
return False, False
session_revoked = False
if not preserve_session:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if session is not None:
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?",
(management_id,),
)
session_revoked = cursor.rowcount == 1
cursor = connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?",
(management_id,),
)
return cursor.rowcount == 1, session_revoked
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_all(self) -> None:
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_device_access(self, management_id: str) -> bool:
"""Atomically remove one active session, its grants, and its linked passkey."""
try:
with self._connect() as connection:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if session is None:
return False
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all_access(self) -> None:
"""Atomically remove every passkey, challenge, grant, and active session."""
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
connection.execute("DELETE FROM step_up_grants")
connection.execute("DELETE FROM active_sessions")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+87
View File
@@ -0,0 +1,87 @@
"""WebAuthn passkey ceremony helpers for the single dashboard operator."""
import base64
import json
import secrets
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
def encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_registration_options(
rp_id=rp_id,
rp_name="Stackchain Dashboard",
user_name="stackchain-operator",
user_display_name="Stackchain operator",
challenge=challenge,
exclude_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in (excluded or [])
],
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
return json.loads(options_to_json(options)), challenge
def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_authentication_options(
rp_id=rp_id,
challenge=challenge,
allow_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
return json.loads(options_to_json(options)), challenge
def verify_registration(
*, credential: dict, challenge: bytes, rp_id: str, origin: str
):
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
require_user_verification=True,
)
def verify_authentication(
*, credential: dict, challenge: bytes, rp_id: str, origin: str, stored
):
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=stored.public_key,
credential_current_sign_count=stored.sign_count,
require_user_verification=True,
)
+306 -52
View File
@@ -2,11 +2,16 @@
from __future__ import annotations
import argparse
import errno
import fcntl
import json
import os
import re
import signal
import shlex
import subprocess
import tempfile
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable, Protocol
@@ -14,6 +19,10 @@ from urllib import error, request
PRIORITY = {"P0": 0, "P1": 1, "P2": 2}
RUNTIME_ENV_NAMES = (
"HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "SHELL",
"SSH_AUTH_SOCK", "TMPDIR", "USER",
)
@dataclass(frozen=True)
@@ -31,6 +40,7 @@ class CommandResult:
returncode: int
stdout: str
stderr: str
timed_out: bool = False
@dataclass(frozen=True)
@@ -42,6 +52,10 @@ class EngineConfig:
agent_command: str
test_command: str
base_branch: str = "main"
agent_timeout_seconds: float = 1800.0
test_timeout_seconds: float = 900.0
termination_grace_seconds: float = 5.0
agent_env_vars: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -52,6 +66,9 @@ class RunState:
evidence: str = ""
pr_number: int | None = None
pr_url: str = ""
schema_version: int = 1
repo: str = ""
head_sha: str = ""
class Client(Protocol):
@@ -63,7 +80,14 @@ class Client(Protocol):
class Runner(Protocol):
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
def run(
self,
command: list[str],
cwd: Path,
env: dict[str, str] | None = None,
timeout_seconds: float | None = None,
termination_grace_seconds: float = 5.0,
) -> CommandResult: ...
class StateStore:
@@ -77,9 +101,71 @@ class StateStore:
def save(self, state: RunState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.write_text(json.dumps(asdict(state), indent=2) + "\n")
tmp.replace(self.path)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=self.path.parent,
prefix=self.path.name + ".",
suffix=".tmp",
delete=False,
) as temporary:
temporary_path = Path(temporary.name)
temporary.write(json.dumps(asdict(state), indent=2) + "\n")
temporary.flush()
os.fsync(temporary.fileno())
os.replace(temporary_path, self.path)
directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
class RepositoryLease:
"""Non-blocking, process-backed single-writer lease for one worktree."""
def __init__(self, repo_path: Path):
self.path = Path(repo_path).resolve() / ".release-engine" / "run.lock"
self._file = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
lock_file = self.path.open("a+", encoding="utf-8")
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
if exc.errno in (errno.EACCES, errno.EAGAIN):
detail = ""
try:
lock_file.seek(0)
metadata = json.loads(lock_file.read(512))
owner_pid = int(metadata["pid"])
acquired_at = float(metadata["acquired_at"])
detail = f" (owner pid={owner_pid}, acquired_at={acquired_at:.3f})"
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
pass
lock_file.close()
raise RuntimeError(f"release run already active{detail}") from exc
lock_file.close()
raise
self._file = lock_file
lock_file.seek(0)
lock_file.truncate()
lock_file.write(json.dumps({"pid": os.getpid(), "acquired_at": time.time()}) + "\n")
lock_file.flush()
os.fsync(lock_file.fileno())
return self
def __exit__(self, exc_type, exc, traceback):
if self._file is not None:
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
self._file.close()
self._file = None
def _label_names(labels: Iterable[Any]) -> list[str]:
@@ -113,20 +199,70 @@ def branch_name(agent: str, issue: Issue) -> str:
return f"{actor}/{issue.number}-{slug or 'issue'}"
def parse_command(command: str, name: str) -> list[str]:
try:
arguments = shlex.split(command)
except ValueError as exc:
raise ValueError(f"invalid {name}: {exc}") from exc
if not arguments:
raise ValueError(f"invalid {name}: command is empty")
return arguments
def subprocess_environment(
allowed_names: Iterable[str] = (),
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
"""Build an explicit child environment without inheriting parent secrets."""
environment = {"PATH": os.environ.get("PATH", os.defpath)}
for name in (*RUNTIME_ENV_NAMES, *allowed_names):
if name in os.environ:
environment[name] = os.environ[name]
if overrides:
environment.update(overrides)
return environment
def validate_agent_env_vars(names: Iterable[str]) -> None:
for name in names:
if (
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None
or name == "GITEA_TOKEN"
or name.startswith("RELEASE_")
):
raise ValueError(f"invalid agent environment variable: {name}")
class ShellRunner:
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
completed = subprocess.run(
def run(
self,
command: list[str],
cwd: Path,
env: dict[str, str] | None = None,
timeout_seconds: float | None = None,
termination_grace_seconds: float = 5.0,
) -> CommandResult:
process = subprocess.Popen(
command,
cwd=str(cwd),
env=merged_env,
shell=True,
env=dict(env or {}),
shell=False,
text=True,
capture_output=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
try:
stdout, stderr = process.communicate(timeout=timeout_seconds)
return CommandResult(process.returncode, stdout, stderr)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGTERM)
try:
stdout, stderr = process.communicate(timeout=termination_grace_seconds)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
stdout, stderr = process.communicate()
return CommandResult(process.returncode, stdout, stderr, timed_out=True)
class GiteaClient:
@@ -197,8 +333,23 @@ class ReleaseEngine:
self.store = StateStore(config.state_path)
def execute(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
with RepositoryLease(self.config.repo_path):
return self._execute_locked(issue_number=issue_number, dry_run=dry_run)
def _execute_locked(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
deadlines = {
"agent timeout seconds": self.config.agent_timeout_seconds,
"test timeout seconds": self.config.test_timeout_seconds,
"termination grace seconds": self.config.termination_grace_seconds,
}
for name, value in deadlines.items():
if value <= 0:
raise ValueError(f"{name} must be positive")
validate_agent_env_vars(self.config.agent_env_vars)
issue = select_issue(self.client.list_open_issues(self.config.repo), self.config.agent, issue_number)
branch = branch_name(self.config.agent, issue)
head_sha = ""
existing_pr = self.client.find_open_pr(self.config.repo, branch)
if existing_pr:
@@ -220,50 +371,138 @@ class ReleaseEngine:
"agent": self.config.agent,
}
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
verified = self.client.get_issue(self.config.repo, issue.number)
if verified.assignee != self.config.agent:
raise RuntimeError(
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
agent_command = parse_command(self.config.agent_command, "agent command")
test_command = parse_command(self.config.test_command, "test command")
runtime_env = subprocess_environment()
agent_env = subprocess_environment(
self.config.agent_env_vars,
{
"RELEASE_ISSUE_NUMBER": str(issue.number),
"RELEASE_ISSUE_TITLE": issue.title,
"RELEASE_ISSUE_BODY": issue.body,
"RELEASE_REPO": self.config.repo,
"RELEASE_BRANCH": branch,
},
)
checkpoint = self.store.load()
resumable_statuses = {"agent_complete", "tests_passed", "pushed"}
if checkpoint is not None and checkpoint.status in resumable_statuses:
if (
checkpoint.schema_version != 1
or checkpoint.repo != self.config.repo
or checkpoint.issue != issue.number
or checkpoint.branch != branch
or not checkpoint.head_sha
):
raise RuntimeError("release checkpoint does not match this run")
branch_result = self.runner.run(
["git", "checkout", branch], self.config.repo_path, runtime_env
)
self.store.save(RunState(issue.number, branch, "claimed"))
if branch_result.returncode != 0:
raise RuntimeError(f"checkpoint branch checkout failed: {branch_result.stderr.strip()}")
head_result = self.runner.run(
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
)
if head_result.returncode != 0 or head_result.stdout.strip() != checkpoint.head_sha:
raise RuntimeError("release checkpoint commit does not match the local branch")
status_result = self.runner.run(
["git", "status", "--porcelain"], self.config.repo_path, runtime_env
)
if status_result.returncode != 0 or status_result.stdout.strip():
raise RuntimeError("release checkpoint worktree is not clean")
head_sha = checkpoint.head_sha
evidence = checkpoint.evidence
else:
checkpoint = None
evidence = ""
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
verified = self.client.get_issue(self.config.repo, issue.number)
if verified.assignee != self.config.agent:
raise RuntimeError(
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
)
self.store.save(RunState(issue.number, branch, "claimed"))
branch_result = self.runner.run(f"git checkout -B {shlex.quote(branch)}", self.config.repo_path)
if branch_result.returncode != 0:
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
branch_result = self.runner.run(
["git", "checkout", "-B", branch], self.config.repo_path, runtime_env
)
if branch_result.returncode != 0:
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
context = {
"issue_number": issue.number,
"issue_title": issue.title,
"issue_body": issue.body,
"repo": self.config.repo,
"branch": branch,
}
command = self.config.agent_command.format(**context)
agent_result = self.runner.run(command, self.config.repo_path, {
"RELEASE_ISSUE_NUMBER": str(issue.number),
"RELEASE_ISSUE_TITLE": issue.title,
"RELEASE_ISSUE_BODY": issue.body,
"RELEASE_REPO": self.config.repo,
"RELEASE_BRANCH": branch,
})
if agent_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
self.store.save(RunState(issue.number, branch, "agent_complete"))
agent_result = self.runner.run(
agent_command,
self.config.repo_path,
agent_env,
timeout_seconds=self.config.agent_timeout_seconds,
termination_grace_seconds=self.config.termination_grace_seconds,
)
if agent_result.timed_out:
evidence = (agent_result.stdout + "\n" + agent_result.stderr).strip()[-2000:]
self.store.save(RunState(issue.number, branch, "agent_timed_out", evidence))
raise RuntimeError(f"coding agent timed out: {evidence}")
if agent_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
head_result = self.runner.run(
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
)
head_sha = head_result.stdout.strip()
if head_result.returncode != 0 or not head_sha:
raise RuntimeError("could not identify the release checkpoint commit")
self.store.save(RunState(
issue.number, branch, "agent_complete",
repo=self.config.repo, head_sha=head_sha,
))
test_result = self.runner.run(self.config.test_command, self.config.repo_path)
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
if test_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
raise RuntimeError(f"tests failed: {evidence}")
self.store.save(RunState(issue.number, branch, "tests_passed", evidence))
if checkpoint is None or checkpoint.status == "agent_complete":
test_result = self.runner.run(
test_command,
self.config.repo_path,
runtime_env,
timeout_seconds=self.config.test_timeout_seconds,
termination_grace_seconds=self.config.termination_grace_seconds,
)
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
if test_result.timed_out:
self.store.save(RunState(
issue.number, branch, "tests_timed_out", evidence,
repo=self.config.repo, head_sha=head_sha,
))
raise RuntimeError(f"tests timed out: {evidence}")
if test_result.returncode != 0:
self.store.save(RunState(
issue.number, branch, "tests_failed", evidence,
repo=self.config.repo, head_sha=head_sha,
))
raise RuntimeError(f"tests failed: {evidence}")
self.store.save(RunState(
issue.number, branch, "tests_passed", evidence,
repo=self.config.repo, head_sha=head_sha,
))
push_result = self.runner.run(f"git push -u origin {shlex.quote(branch)}", self.config.repo_path)
if push_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "push_failed", push_result.stderr[-2000:]))
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
self.store.save(RunState(issue.number, branch, "pushed", evidence))
if checkpoint is not None and checkpoint.status == "pushed":
remote_result = self.runner.run(
["git", "ls-remote", "--heads", "origin", branch],
self.config.repo_path,
runtime_env,
)
expected_remote = f"{checkpoint.head_sha}\trefs/heads/{branch}"
if remote_result.returncode != 0 or remote_result.stdout.strip() != expected_remote:
raise RuntimeError("release checkpoint commit does not match the remote branch")
else:
push_result = self.runner.run(
["git", "push", "-u", "origin", branch], self.config.repo_path, runtime_env
)
if push_result.returncode != 0:
self.store.save(RunState(
issue.number, branch, "push_failed", push_result.stderr[-2000:],
repo=self.config.repo, head_sha=head_sha,
))
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
self.store.save(RunState(
issue.number, branch, "pushed", evidence,
repo=self.config.repo, head_sha=head_sha,
))
pr_body = (
f"Closes #{issue.number}\n\n"
@@ -288,6 +527,8 @@ class ReleaseEngine:
evidence,
pr_number=pr["number"],
pr_url=pr.get("html_url", ""),
repo=self.config.repo,
head_sha=head_sha,
)
self.store.save(final)
return {
@@ -310,6 +551,15 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--api", default=os.getenv("GITEA_API", "http://127.0.0.1:3000/api/v1"))
parser.add_argument("--agent-command", default=os.getenv("RELEASE_AGENT_COMMAND", ""))
parser.add_argument("--test-command", default=os.getenv("RELEASE_TEST_COMMAND", "python3 -m pytest tests/ -q"))
parser.add_argument("--agent-timeout", type=float, default=float(os.getenv("RELEASE_AGENT_TIMEOUT", "1800")))
parser.add_argument("--test-timeout", type=float, default=float(os.getenv("RELEASE_TEST_TIMEOUT", "900")))
parser.add_argument("--termination-grace", type=float, default=float(os.getenv("RELEASE_TERMINATION_GRACE", "5")))
parser.add_argument(
"--agent-env",
action="append",
default=[name for name in os.getenv("RELEASE_AGENT_ENV", "").split(",") if name],
help="parent environment variable to pass to the coding agent; repeat as needed",
)
return parser.parse_args()
@@ -327,6 +577,10 @@ def main() -> int:
state_path=args.state,
agent_command=args.agent_command,
test_command=args.test_command,
agent_timeout_seconds=args.agent_timeout,
test_timeout_seconds=args.test_timeout,
termination_grace_seconds=args.termination_grace,
agent_env_vars=tuple(args.agent_env),
)
result = ReleaseEngine(config, GiteaClient(args.api, token), ShellRunner()).execute(
issue_number=args.issue,
+116
View File
@@ -0,0 +1,116 @@
from collections.abc import Callable
from starlette.types import ASGIApp, Message, Receive, Scope, Send
SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
def request_body_limit(method: str, path: str) -> int | None:
"""Return the admission limit for request bodies that FastAPI will parse."""
normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT
if (
normalized_method == "POST"
and (
(path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
or path.startswith("/api/v1/notifications/")
)
and path.endswith("/attachments")
):
return ISSUE_ATTACHMENT_BODY_LIMIT
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
return API_MUTATION_BODY_LIMIT
return None
class RequestBodyLimitMiddleware:
"""Reject oversized API bodies before framework parsing or endpoint work."""
def __init__(
self,
app: ASGIApp,
limit_for: Callable[[str, str], int | None] = request_body_limit,
) -> None:
self.app = app
self.limit_for = limit_for
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
root_path = scope.get("root_path", "").rstrip("/")
if root_path and (path == root_path or path.startswith(root_path + "/")):
path = path[len(root_path) :] or "/"
limit = self.limit_for(scope.get("method", "GET"), path)
if limit is None:
await self.app(scope, receive, send)
return
if (
limit == ISSUE_ATTACHMENT_BODY_LIMIT
and self._header(scope, b"content-type").startswith(b"application/json")
):
limit = LEGACY_JSON_ATTACHMENT_BODY_LIMIT
declared_length = self._content_length(scope)
if declared_length is not None and declared_length > limit:
await self._reject(send)
return
messages: list[Message] = []
received = 0
while True:
message = await receive()
messages.append(message)
if message["type"] != "http.request":
break
received += len(message.get("body", b""))
if received > limit:
await self._reject(send)
return
if not message.get("more_body", False):
break
async def replay() -> Message:
if messages:
return messages.pop(0)
return {"type": "http.request", "body": b"", "more_body": False}
await self.app(scope, replay, send)
@staticmethod
def _header(scope: Scope, wanted: bytes) -> bytes:
for name, value in scope.get("headers", []):
if name.lower() == wanted:
return value.lower()
return b""
@staticmethod
def _content_length(scope: Scope) -> int | None:
for name, value in scope.get("headers", []):
if name.lower() != b"content-length":
continue
try:
parsed = int(value)
except ValueError:
return None
return max(0, parsed)
return None
@staticmethod
async def _reject(send: Send) -> None:
body = b'{"detail":"Request body too large"}'
headers = [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
(b"cache-control", b"no-store"),
]
await send({"type": "http.response.start", "status": 413, "headers": headers})
await send({"type": "http.response.body", "body": body})
+219
View File
@@ -0,0 +1,219 @@
"""Bounded, privacy-preserving journal of operator security activity."""
import secrets
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
class SecurityEventStoreError(RuntimeError):
"""Raised when security activity cannot be persisted or read safely."""
@dataclass(frozen=True)
class SecurityEvent:
id: int
kind: str
method: str | None
device_label: str | None
target: str | None
created_at: int
status: str
@dataclass(frozen=True)
class SecurityEventPage:
events: list[SecurityEvent]
next_cursor: int | None
class SecurityEventStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_events: int = 10_000,
retention_seconds: int = 90 * 24 * 60 * 60,
lock_timeout_seconds: float = 0.1,
) -> 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
@staticmethod
def _bounded(value: str | None, limit: int) -> str | None:
if value is None:
return None
normalized = " ".join(str(value).split())[:limit]
return normalized or None
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS 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
)
"""
)
columns = {
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
}
if "status" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
)
if "operation_id" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)"
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
connection.execute(
"DELETE FROM security_events WHERE created_at < ?",
(now - self.retention_seconds,),
)
connection.execute(
"DELETE FROM security_events WHERE id NOT IN "
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
(self.max_events,),
)
def record(
self,
kind: str,
*,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
) -> None:
now = int(self.clock())
try:
with self._connect() as connection:
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,
),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def reserve(
self,
kind: str,
*,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
) -> str:
now = int(self.clock())
operation_id = secrets.token_urlsafe(24)
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
(
self._bounded(kind, 48) or "security_event",
self._bounded(method, 32),
self._bounded(device_label, 64),
self._bounded(target, 255),
now,
operation_id,
),
)
self._prune(connection, now)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
return operation_id
def finalize(self, operation_id: str) -> None:
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
(operation_id,),
)
if cursor.rowcount != 1:
raise SecurityEventStoreError(
"Security activity reservation was not found"
)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def discard(self, operation_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
(operation_id,),
)
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
bounded_limit = min(100, max(1, limit))
parameters: list[int] = []
where = ""
if cursor is not None:
where = "WHERE id < ?"
parameters.append(cursor)
parameters.append(bounded_limit + 1)
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT id, kind, method, device_label, target, created_at, status "
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
parameters,
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SecurityEventStoreError(
"Security activity is temporarily unavailable"
) from exc
has_more = len(rows) > bounded_limit
visible = rows[:bounded_limit]
return SecurityEventPage(
events=[SecurityEvent(*row) for row in visible],
next_cursor=visible[-1][0] if has_more else None,
)
+291 -5
View File
@@ -1,7 +1,9 @@
"""Durable active-session registry used to revoke signed operator sessions."""
import hashlib
import secrets
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
@@ -10,6 +12,26 @@ class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely."""
class SessionStatus(str):
"""String-compatible status carrying the server-confirmed idle deadline."""
idle_expires_at: int | None
def __new__(cls, value: str, idle_expires_at: int | None = None):
instance = super().__new__(cls, value)
instance.idle_expires_at = idle_expires_at
return instance
@dataclass(frozen=True)
class ActiveDevice:
management_id: str
device_label: str
created_at: int
expires_at: int
current: bool
class SessionStore:
def __init__(
self,
@@ -44,23 +66,94 @@ class SessionStore:
"""
CREATE TABLE IF NOT EXISTS active_sessions (
session_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL,
management_id TEXT,
device_label TEXT,
created_at INTEGER,
last_active_at INTEGER
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS step_up_grants (
grant_hash TEXT PRIMARY KEY,
session_hash TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS step_up_grants_session_hash "
"ON step_up_grants(session_hash)"
)
columns = {
row[1] for row in connection.execute("PRAGMA table_info(active_sessions)")
}
additions = {
"management_id": "TEXT",
"device_label": "TEXT",
"created_at": "INTEGER",
"last_active_at": "INTEGER",
}
for name, column_type in additions.items():
if name not in columns:
connection.execute(
f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}"
)
connection.execute(
"UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) "
"WHERE management_id IS NULL"
)
connection.execute(
"UPDATE active_sessions SET device_label = 'Existing device' "
"WHERE device_label IS NULL"
)
connection.execute(
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
"ON active_sessions(management_id)"
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def activate(self, session_id: str, expires_at: int) -> None:
def activate(
self,
session_id: str,
expires_at: int,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> None:
label = " ".join(str(device_label).split())[:64] or "This device"
now = int(self.clock())
try:
with self._connect(initialize=True) as connection:
connection.execute(
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
"DELETE FROM active_sessions WHERE expires_at <= ?", (now,)
)
connection.execute(
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
(self._digest(session_id), expires_at),
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
management_id or secrets.token_urlsafe(18),
label,
now,
now,
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
@@ -77,12 +170,205 @@ class SessionStore:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return row is not None and row[0] == expires_at and expires_at > now
def revoke(self, session_id: str) -> None:
def status(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> str:
now = int(self.clock())
query = (
"SELECT expires_at, last_active_at FROM active_sessions "
"WHERE session_hash = ?"
)
parameters = (self._digest(session_id),)
try:
try:
with self._connect() as connection:
row = connection.execute(query, parameters).fetchone()
except sqlite3.OperationalError as exc:
if "no such column: last_active_at" not in str(exc):
raise
with self._connect(initialize=True) as connection:
row = connection.execute(query, parameters).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] != expires_at or expires_at <= now:
return SessionStatus("revoked")
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
if idle_expires_at <= now:
return SessionStatus("idle", idle_expires_at)
return SessionStatus("active", idle_expires_at)
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE active_sessions SET last_active_at = ? "
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
"AND last_active_at + ? > ?",
(
now,
self._digest(session_id),
expires_at,
now,
max(1, idle_timeout_seconds),
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke(self, session_id: str) -> None:
try:
with self._connect(initialize=True) as connection:
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?",
(self._digest(session_id),),
)
connection.execute(
"DELETE FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
now = int(self.clock())
current_hash = self._digest(current_session_id)
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT management_id, device_label, created_at, expires_at, session_hash "
"FROM active_sessions WHERE expires_at > ? "
"ORDER BY expires_at DESC, created_at DESC",
(now,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return [
ActiveDevice(
management_id=row[0],
device_label=row[1],
created_at=row[2],
expires_at=row[3],
current=secrets.compare_digest(row[4], current_hash),
)
for row in rows
]
def management_id(self, session_id: str) -> str:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT management_id FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None:
raise SessionStoreError("Session is no longer active")
return row[0]
def revoke_managed(self, management_id: str) -> bool:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
).fetchone()
if row is None:
return False
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],)
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all(self) -> None:
try:
with self._connect(initialize=True) as connection:
connection.execute("DELETE FROM step_up_grants")
connection.execute("DELETE FROM active_sessions")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def mint_step_up(
self,
session_id: str,
*,
action: str,
target: str,
ttl_seconds: int,
) -> str:
now = int(self.clock())
grant = secrets.token_urlsafe(32)
session_hash = self._digest(session_id)
try:
with self._connect(initialize=True) as connection:
active = connection.execute(
"SELECT 1 FROM active_sessions "
"WHERE session_hash = ? AND expires_at > ?",
(session_hash, now),
).fetchone()
if active is None:
raise SessionStoreError("Session is no longer active")
connection.execute(
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
)
connection.execute(
"INSERT INTO step_up_grants("
"grant_hash, session_hash, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?)",
(
self._digest(grant),
session_hash,
action,
target,
now + max(1, ttl_seconds),
),
)
except SessionStoreError:
raise
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return grant
def consume_step_up(
self,
grant: str,
session_id: str,
*,
action: str,
target: str,
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
)
cursor = connection.execute(
"DELETE FROM step_up_grants WHERE grant_hash = ? "
"AND session_hash = ? AND action = ? AND target = ? "
"AND expires_at > ? AND EXISTS ("
"SELECT 1 FROM active_sessions "
"WHERE active_sessions.session_hash = step_up_grants.session_hash "
"AND active_sessions.expires_at > ?)",
(
self._digest(grant),
self._digest(session_id),
action,
target,
now,
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+304
View File
@@ -0,0 +1,304 @@
"""Durable, account-scoped ordered Today plans."""
import json
import sqlite3
import time
from pathlib import Path
class TodayPlanFull(ValueError):
"""Raised when an add would exceed the bounded Today plan."""
class TodayStore:
def __init__(
self,
path: str | Path,
*,
limit: int = 5,
timeout: float = 1.0,
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
clock=time.time,
):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
def _connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_plans (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
ids TEXT NOT NULL,
capacity_minutes INTEGER,
estimates TEXT NOT NULL DEFAULT '{}'
)
"""
)
plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
if "capacity_minutes" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
if "estimates" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_operations (
login TEXT NOT NULL,
operation_id TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (login, operation_id)
)
"""
)
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
if "created_at" not in columns:
connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
connection.execute(
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
(self.clock(),),
)
connection.commit()
return connection
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
now = self.clock()
connection.execute(
"INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
(login, operation_id, now),
)
connection.execute(
"DELETE FROM today_operations WHERE login = ? AND created_at < ?",
(login, now - self.operation_retention_seconds),
)
connection.execute(
"DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
"(SELECT rowid FROM today_operations WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
(login, login, self.operation_limit),
)
@staticmethod
def _normalize_login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _snapshot(row) -> dict:
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": int(row[0]),
"ids": ids,
"capacity_minutes": row[2],
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
def apply(
self,
login: str,
operation_id: str,
action: str,
item_id: str,
*,
direction: str | None = None,
) -> dict:
login = self._normalize_login(login)
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"add", "remove", "move"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
return snapshot
ids = list(snapshot["ids"])
estimates = dict(snapshot["estimates"])
changed = False
if action == "add":
if item_id not in ids:
if len(ids) >= self.limit:
raise TodayPlanFull("Today is limited to five items")
ids.append(item_id)
changed = True
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
estimates.pop(item_id, None)
changed = True
else:
try:
index = ids.index(item_id)
except ValueError:
index = -1
target = index - 1 if direction == "up" else index + 1
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
revision = snapshot["revision"] + (1 if changed else 0)
serialized_ids = json.dumps(ids, separators=(",", ":"))
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
)
elif changed:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
(revision, serialized_ids, serialized_estimates, login),
)
self._record_operation(connection, login, operation_id)
return {
"revision": revision,
"ids": ids,
"capacity_minutes": snapshot["capacity_minutes"],
"estimates": estimates,
}
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply an ordered batch with one lock and receipt per operation."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
ids = list(snapshot["ids"])
capacity_minutes = snapshot["capacity_minutes"]
estimates = dict(snapshot["estimates"])
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
rejected: list[dict[str, str]] = []
for operation in operations:
operation_id = operation.get("operation_id", "")
action = operation.get("action", "")
item_id = operation.get("item_id", "")
direction = operation.get("direction")
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"add", "remove", "move", "configure"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
duplicate = connection.execute(
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
duplicates.append(operation_id)
continue
base_revision = operation.get("base_revision")
if base_revision is None:
base_revision = snapshot["revision"]
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
raise ValueError("base_revision must be a non-negative integer")
if base_revision < snapshot["revision"]:
self._record_operation(connection, login, operation_id)
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
continue
changed = False
if action == "add":
if item_id not in ids:
if len(ids) >= self.limit:
rejected.append({"operation_id": operation_id, "reason": "today_full"})
continue
ids.append(item_id)
changed = True
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
estimates.pop(item_id, None)
changed = True
elif action == "move":
try:
index = ids.index(item_id)
except ValueError:
index = -1
target = index - 1 if direction == "up" else index + 1
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
else:
proposed_capacity = operation.get("capacity_minutes")
if proposed_capacity is not None and (
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
or proposed_capacity < 15 or proposed_capacity > 1440
):
raise ValueError("capacity_minutes must be between 15 and 1440")
proposed_estimates = operation.get("estimates", {})
if not isinstance(proposed_estimates, dict):
raise ValueError("estimates must be an object")
normalized_estimates = {}
for estimate_id, minutes in proposed_estimates.items():
if estimate_id not in ids:
continue
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
raise ValueError("estimate minutes must be between 5 and 1440")
normalized_estimates[estimate_id] = minutes
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
capacity_minutes = proposed_capacity
estimates = normalized_estimates
revision += 1 if changed else 0
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":"))
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
(login, revision, serialized, capacity_minutes, serialized_estimates),
)
elif accepted:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
(revision, serialized, capacity_minutes, serialized_estimates, login),
)
return {
"revision": revision,
"ids": ids,
"capacity_minutes": capacity_minutes,
"estimates": estimates,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
}
+51 -11
View File
@@ -1,20 +1,23 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse, HTMLResponse
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from src.frontend_bundle import build_frontend
from src.compression import _quality
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
FRONTEND_BUILD = build_frontend(DASHBOARD_FILE.parent)
LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
<script src="static/login.js"></script></body></html>"""
<body><main><h1>Operator sign in</h1><p>Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
class RevalidatingHTMLResponse(HTMLResponse):
@@ -26,7 +29,7 @@ class RevalidatingHTMLResponse(HTMLResponse):
@router.get("/", response_class=RevalidatingHTMLResponse)
async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
return FRONTEND_BUILD.dashboard_html
@router.get("/login", response_class=RevalidatingHTMLResponse)
@@ -39,10 +42,47 @@ async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
@router.get("/service-worker.js", response_class=FileResponse)
async def service_worker() -> FileResponse:
return FileResponse(
SERVICE_WORKER_FILE,
@router.get("/service-worker.js")
async def service_worker() -> Response:
return Response(
FRONTEND_BUILD.service_worker_source,
media_type="application/javascript",
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
)
@router.get("/runtime-{digest}.js")
async def runtime_bundle(digest: str, request: Request) -> Response:
if digest != FRONTEND_BUILD.runtime_digest:
raise HTTPException(status_code=404, detail="Runtime revision not found")
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
return Response(
(
FRONTEND_BUILD.runtime_gzip_bytes
if accepts_gzip
else FRONTEND_BUILD.runtime_bytes
),
media_type="application/javascript",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept-Encoding",
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
@router.get("/feature-{name}-{digest}.js")
async def feature_bundle(name: str, digest: str, request: Request) -> Response:
bundle = FRONTEND_BUILD.feature_bundles.get(name)
if bundle is None or digest != bundle.runtime_digest:
raise HTTPException(status_code=404, detail="Feature revision not found")
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
return Response(
bundle.runtime_gzip_bytes if accepts_gzip else bundle.runtime_bytes,
media_type="application/javascript",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept-Encoding",
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
+38
View File
@@ -0,0 +1,38 @@
import re
from pathlib import Path
from src.views import dashboard as dashboard_html
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
PAGE_SCRIPT_MANIFEST = "\n".join(
re.findall(
r'^<script src="static/[^\"]+\.js"></script>$',
(FRONTEND / "index.html").read_text(),
re.MULTILINE,
)
)
def dashboard_bundle_text() -> str:
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
return "\n".join(
(
(FRONTEND / "index.html").read_text(),
(FRONTEND / "dashboard.css").read_text(),
(FRONTEND / "dashboard.js").read_text(),
)
)
async def dashboard() -> str:
# Exercise the real view lookup before adding its separately served bootstrap assets.
html = await dashboard_html()
return "\n".join(
(
html,
PAGE_SCRIPT_MANIFEST,
(FRONTEND / "dashboard.css").read_text(),
(FRONTEND / "dashboard.js").read_text(),
)
)
+97
View File
@@ -0,0 +1,97 @@
import sqlite3
import pytest
from src.passkey_store import PasskeyStore
from src.session_store import SessionStore, SessionStoreError
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
sessions.activate(
"phone-session",
2_000,
management_id="phone-management-id",
device_label="Phone",
)
sessions.mint_step_up(
"phone-session",
action="revoke_all_sessions",
target="all",
ttl_seconds=90,
)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
passkeys.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
)
passkeys.issue_challenge(
b"pending-challenge",
session_id="phone-session",
purpose="authentication",
action="sign_in",
target="dashboard",
)
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TRIGGER block_session_delete BEFORE DELETE ON active_sessions "
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
)
with pytest.raises(SessionStoreError):
passkeys.revoke_all_access()
with sqlite3.connect(database) as connection:
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
for label in ("Phone", "Laptop"):
slug = label.lower()
sessions.activate(
f"{slug}-session",
2_000,
management_id=f"{slug}-management-id",
device_label=label,
)
sessions.mint_step_up(
f"{slug}-session",
action="close_issue",
target="stackchain/dashboard#1",
ttl_seconds=90,
)
passkeys.register(
credential_id=f"{slug}-credential".encode(),
public_key=f"{slug}-public-key".encode(),
sign_count=0,
device_label=label,
management_id=f"{slug}-management-id",
)
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TRIGGER block_phone_delete BEFORE DELETE ON active_sessions "
"WHEN OLD.management_id = 'phone-management-id' "
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
)
with pytest.raises(SessionStoreError):
passkeys.revoke_device_access("phone-management-id")
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT management_id FROM active_sessions ORDER BY management_id"
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
assert connection.execute(
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
+2 -3
View File
@@ -2,12 +2,11 @@ import re
from pathlib import Path
from urllib.parse import urljoin
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
from tests.dashboard_bundle import dashboard_bundle_text
def test_api_requests_resolve_inside_dashboard_subpath():
html = DASHBOARD_HTML.read_text()
html = dashboard_bundle_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
assert api_paths
+276
View File
@@ -0,0 +1,276 @@
import json
import subprocess
from pathlib import Path
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
script = f"""
const fs=require('fs');
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
let release;
const claimResult=new Promise(resolve=>{{release=resolve;}});
const flow=createAssignAndStart({{
available:()=>true,
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const issue={{repository:'stackchain/dashboard',number:411}};
const first=flow.run(issue);
const second=flow.run(issue);
release({{...issue,assignees:['timmy']}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
}}
"""
assert run_node(script) == {
"available": True,
"calls": [
"claim:411",
"start:411",
"announce:Assigned, added to Today, and ready to work.",
],
"results": ["started", "started"],
"same": True,
}
def test_full_today_blocks_before_assignment_and_explains_recovery():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const flow=createAssignAndStart({{
available:()=>false,
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
start:()=>calls.push('start'),
recover:()=>calls.push('recover'),
announce:message=>calls.push('announce:'+message),
}});
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "full",
"calls": ["announce:Today is full—remove an item before assigning this issue."],
}
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>Promise.resolve(issue),
start:()=> 'sync-unavailable',
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": "recovery",
"calls": [
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
"recover:411",
],
}
def test_failed_assignment_does_not_start_today_and_can_be_retried():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];let attempt=0;
const issue={{repository:'stackchain/dashboard',number:411}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
start:()=>{{calls.push('start');return 'started';}},
recover:()=>calls.push('recover'),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue).catch(error=>{{
calls.push('error:'+error.message);
return flow.run(issue);
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": "started",
"calls": [
"claim:1",
"error:conflict",
"claim:2",
"start",
"announce:Assigned, added to Today, and ready to work.",
],
}
def test_already_owned_search_result_starts_without_claiming():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue, {{alreadyOwned:true}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "started",
"calls": [
"start:413",
"announce:Added to Today and ready to work.",
],
}
def test_queue_today_claims_once_without_starting_the_active_session():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
let release;
const claimResult=new Promise(resolve=>{{release=resolve;}});
const issue={{repository:'stackchain/dashboard',number:415}};
const flow=createAssignAndStart({{
available:()=>true,
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const first=flow.run(issue, {{destination:'queue'}});
const second=flow.run(issue, {{destination:'queue'}});
release({{...issue,assignees:['timmy']}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
calls,results,same:first===second
}})));
"""
assert run_node(script) == {
"calls": [
"claim:415",
"queue:415",
"announce:Assigned and queued in Today. Keep finding work when ready.",
],
"results": ["queued", "queued"],
"same": True,
}
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
script = f"""
const fs=require('fs');
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
const calls=[];
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
const queue=createQueueToday({{
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'),
warm:()=>calls.push('warm'),
}});
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
}}
"""
assert run_node(script) == {
"available": True,
"result": "queued",
"calls": [
"add",
"add:issue:stackchain/dashboard:415:",
"refresh",
"flush",
"warm",
],
}
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>Promise.resolve(issue),
start:()=> 'started',
queue:()=> 'sync-unavailable',
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue, {{destination:'queue'}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "recovery",
"calls": [
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
"recover:415",
],
}
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert "data-claim-start-index" in dashboard
assert "data-claim-queue-index" in dashboard
assert ">Start now</button>" in dashboard
assert ">Queue Today</button>" in dashboard
assert "const assignAndStart = createAssignAndStart({" in dashboard
assert "assignAndStart.run(item)" in dashboard
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
assert "queue: confirmed =>" in dashboard
assert "const queueToday = createQueueToday({" in dashboard
assert "return queueToday(claimed)" in dashboard
assert "createAndStart.available" in dashboard
assert "createAndStart.complete" in dashboard
assert ".find-work-claim-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
assert ".find-work-claim-actions [data-claim-start-index]" in css
assert "grid-column:1 / -1" in css
assert "BASE + 'static/assign-and-start.js'" in worker
assert "BASE + 'static/queue-today.js'" in worker
assert '<script src="static/queue-today.js"></script>' in html
+93
View File
@@ -116,6 +116,54 @@ async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatc
assert calls == 1
@pytest.mark.anyio
async def test_live_authored_task_remains_joinable_after_reservation_age_threshold(
monkeypatch, tmp_path
):
calls = 0
now = [1_000.0]
release = asyncio.Event()
async def reply(_thread_id, body):
nonlocal calls
calls += 1
await release.wait()
return {"id": 319, "body": body}
monkeypatch.setattr(
main,
"_idempotency_ledger",
IdempotencyLedger(
tmp_path / "live.sqlite3",
ttl_seconds=1,
max_entries=256,
clock=lambda: now[0],
),
)
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "live-after-threshold-319"}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
timed_out = await client.post(
"/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers,
)
now[0] += 2
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 1.0)
joined = asyncio.create_task(client.post(
"/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers,
))
await asyncio.sleep(0.02)
assert not joined.done(), "a live local operation must be joined instead of rejected as stale"
release.set()
recovered = await joined
assert timed_out.status_code == 503
assert recovered.status_code == 201
assert calls == 1
@pytest.mark.anyio
async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
calls = []
@@ -233,6 +281,51 @@ async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
assert calls == 0
@pytest.mark.anyio
async def test_stale_orphaned_comment_returns_non_retryable_uncertain_delivery(
monkeypatch, tmp_path
):
calls = 0
now = [1_000.0]
async def assigned(_repository, _number):
return True
async def comment(_repository, _number, _body):
nonlocal calls
calls += 1
return {"id": 319}
ledger = IdempotencyLedger(
tmp_path / "stale-orphan.sqlite3",
ttl_seconds=10,
max_entries=256,
clock=lambda: now[0],
)
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
assert ledger.reserve("stale-orphan-319", fingerprint).state == "reserved"
now[0] += 11
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/issues/7/comments",
json={"body": "Verify first"},
headers={"Idempotency-Key": "stale-orphan-319"},
)
assert response.status_code == 422
assert response.json()["detail"] == {
"code": "delivery_uncertain",
"message": "Delivery could not be confirmed. Verify it was not posted before retrying.",
}
assert "Retry-After" not in response.headers
assert calls == 0
@pytest.mark.anyio
async def test_slow_ledger_reservation_does_not_block_health_requests(monkeypatch):
async def assigned(_repository, _number):
+386 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
@@ -77,6 +77,191 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
def test_authored_outbox_persists_and_delivers_desired_blocker_state():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{
calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
}},
}});
const queued = outbox.enqueue({{
kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op',
blockerRepository:'stackchain/api',blockerNumber:9,present:false,
}});
const restored = createAuthoredOutbox({{storage}}).list()[0];
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}})));
"""
output = run_node(script)
assert output["queued"]["blockerRepository"] == "stackchain/api"
assert output["queued"]["blockerNumber"] == 9
assert output["queued"]["present"] is False
assert output["restored"]["present"] is False
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
"method": "PATCH",
"key": "blocker-op",
"body": {"repository": "stackchain/api", "number": 9, "present": False},
}]
assert output["result"]["confirmed"][0]["dependencies"] == []
assert output["remaining"] == []
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let release; const gate = new Promise(resolve => release = resolve);
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy', now:()=>777,
fetchJson:async()=>{{await gate; const error=new Error('Network unavailable');error.status=503;throw error;}},
}});
outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Wait',operationId:'attempt'}});
const pending=outbox.flush('timmy');
const sending=outbox.list()[0];
release();
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
"""
output = run_node(script)
assert output["sending"]["status"] == "sending"
assert output["after"]["status"] == "queued"
assert output["after"]["lastAttemptAt"] == 777
assert output["after"]["lastAttemptError"] == "Network unavailable"
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{id:44}}; }},
}});
let queued;
try {{
queued = outbox.enqueue({{
kind:'pull-review', repository:'stackchain/web', number:8, operationId:'review-op',
body:'Looks good', decision:'approve', expectedHeadSha:'abc123',
comments:[{{path:'app.js',body:'Nice',new_position:4}}],
draftKey:'stackchain.review-draft.v1:stackchain/web#8@abc123',
progressKey:'stackchain.review-progress.v1:stackchain/web#8@abc123',
draftFingerprint:'draft', progressFingerprint:'progress',
}});
}} catch (error) {{
process.stdout.write(JSON.stringify({{error:error.message}}));
}}
if (queued) {{
storage.setItem(queued.draftKey, 'draft'); storage.setItem(queued.progressKey, 'progress');
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued, calls:calls.map(call=>({{url:call.url,key:call.options.headers['Idempotency-Key'],body:JSON.parse(call.options.body)}})),
result, remaining:outbox.list(), draft:storage.getItem(queued.draftKey), progress:storage.getItem(queued.progressKey),
}})));
}}
"""
output = run_node(script)
assert "error" not in output, output.get("error")
assert output["queued"]["kind"] == "pull-review"
assert output["queued"]["comments"] == [{"path": "app.js", "body": "Nice", "new_position": 4}]
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/web/pulls/8/review",
"key": "review-op",
"body": {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
},
}]
assert output["remaining"] == []
assert output["draft"] is None
assert output["progress"] is None
def test_authored_outbox_requires_explicit_retry_to_authorize_issue_closure():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{ calls.push({{url,options}}); return {{number:27,state:'closed'}}; }},
}});
const queued = outbox.enqueue({{
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
}});
(async()=>{{
const automatic = await outbox.flush('timmy');
const awaitingAuthorization = outbox.list();
const explicit = await outbox.retry(queued.id, 'timmy');
process.stdout.write(JSON.stringify({{
queued,automatic,awaitingAuthorization,explicit,calls,remaining:outbox.list()
}}));
}})();
"""
output = run_node(script)
assert output["queued"]["kind"] == "issue-close"
assert output["automatic"]["confirmed"] == []
assert output["awaitingAuthorization"][0]["status"] == "queued"
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
"options": {
"method": "PATCH",
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
},
}]
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
def test_queued_closure_stays_durable_through_background_skip_then_closes_from_explicit_retry():
sync = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
script = f"""
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
const createBackgroundIssueSync=require({json.dumps(str(sync))});
const values=new Map();const records=new Map();let tail=Promise.resolve();const calls=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const transaction=work=>{{const run=tail.then(()=>work({{
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
const fetchJson=async(url,options={{}})=>{{
calls.push({{url,key:options.headers?.['Idempotency-Key'] || ''}});
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};
}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
const background=createBackgroundIssueSync({{store,fetchJson}});background.requestSync=async()=>{{}};
const outbox=createAuthoredOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',backgroundSync:background}});
const admitted=await outbox.enqueueDurably({{kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'}});
const automatic=await background.flush();
outbox.reconcileBackground(await background.snapshot());
const foregroundFlush=await outbox.flush('timmy');
const explicit=await outbox.retry(admitted.item.id,'timmy');
process.stdout.write(JSON.stringify({{automatic,foregroundFlush,explicit,calls,remaining:outbox.list(),snapshot:await background.snapshot()}}));
}})();
"""
output = run_node(script)
assert output["automatic"]["confirmed"] == []
assert output["foregroundFlush"]["confirmed"] == []
assert output["calls"] == [
{"url": "api/v1/background-identity", "key": ""},
{"url": "api/v1/repos/stackchain/dashboard/issues/27/close", "key": "close-op"},
]
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
assert output["snapshot"][0]["status"] == "sent"
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -113,6 +298,37 @@ outbox.flush('timmy').then(async first => {{
assert output["remaining"][1]["status"] == "queued"
def test_authored_outbox_requires_deliberate_rekey_after_uncertain_delivery():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); let sequence=0; const calls=[];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'verified-' + (++sequence),
fetchJson:async (_url, options) => {{
const key=options.headers['Idempotency-Key']; calls.push(key);
if (key === 'uncertain-key') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
return {{id:calls.length}};
}},
}});
const uncertain=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Check',operationId:'uncertain-key'}});
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Continue',operationId:'other-key'}});
(async()=>{{
const first=await outbox.flush('timmy');
const attention=outbox.list()[0];
const retried=await outbox.retry(uncertain.id,'timmy');
process.stdout.write(JSON.stringify({{first,attention,retried,calls,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["calls"] == ["uncertain-key", "other-key", "verified-1"]
assert len(output["first"]["confirmed"]) == 1
assert output["attention"]["status"] == "attention"
assert output["attention"]["deliveryState"] == "uncertain"
assert output["remaining"] == []
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -152,6 +368,52 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
assert len(output["items"]) == 1
def test_authored_outbox_deduplicates_pull_review_for_same_head_after_reload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();let sequence=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const make=()=>createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-' + (++sequence)}});
const first=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
const second=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
process.stdout.write(JSON.stringify({{first,second,items:make().list()}}));
"""
output = run_node(script)
assert output["first"] == output["second"]
assert len(output["items"]) == 1
def test_authored_outbox_rejects_changed_feedback_while_same_head_is_queued():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'First'}});
let error='';try{{outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'request_changes',body:'Changed'}});}}catch(e){{error=e.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
"""
output = run_node(script)
assert output["error"] == "A review for this saved head is already queued. Open Drafts to inspect or discard it first."
assert len(output["items"]) == 1
def test_confirmed_review_keeps_draft_edits_made_after_it_was_queued():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const draftKey='draft-key';const progressKey='progress-key';storage.setItem(draftKey,'old-draft');storage.setItem(progressKey,'old-progress');
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',fetchJson:async()=>({{id:1}})}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',body:'Queued',draftKey,progressKey,draftFingerprint:'old-draft',progressFingerprint:'old-progress'}});
storage.setItem(draftKey,'newer-draft');storage.setItem(progressKey,'newer-progress');
outbox.flush('timmy').then(()=>process.stdout.write(JSON.stringify({{draft:storage.getItem(draftKey),progress:storage.getItem(progressKey)}})));
"""
output = run_node(script)
assert output == {"draft": "newer-draft", "progress": "newer-progress"}
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -182,6 +444,21 @@ setTimeout(() => {{
]
def test_authored_outbox_reconciles_uncertain_worker_delivery_state():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map(); const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:7,body:'Maybe',operationId:'maybe'}});
const reconciled=outbox.reconcileBackground([{{...item,status:'attention',deliveryState:'uncertain',error:'Verify first'}}]);
process.stdout.write(JSON.stringify(reconciled[0]));
"""
output = run_node(script)
assert output["status"] == "attention"
assert output["deliveryState"] == "uncertain"
def test_authored_outbox_waits_for_durable_background_admission():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -238,6 +515,35 @@ outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',ope
assert output["remaining"] == []
def test_authorization_hold_can_resume_in_foreground_with_original_review_identity():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const sent=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{
storage,getOwnerLogin:()=>'timmy',
backgroundSync:{{reconcile:async()=>{{}},requestSync:async()=>{{}},
send:async item=>{{sent.push({{operationId:item.operationId,status:item.status,
head:item.expectedHeadSha,decision:item.decision}});return{{message:{{id:91}}}};}}}},
}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,operationId:'review-stable',
body:'Ship it',decision:'approve',expectedHeadSha:'abc123'}});
outbox.reconcileBackground([{{id:'review-stable',kind:'pull-review',status:'authorization',
error:'Fresh authorization required'}}]);
(async()=>{{const held=outbox.list()[0];const result=await outbox.retry(held.id,'timmy');
process.stdout.write(JSON.stringify({{held,sent,result,remaining:outbox.list()}}));}})();
"""
output = run_node(script)
assert output["held"]["status"] == "authorization"
assert output["sent"] == [{
"operationId": "review-stable", "status": "authorization",
"head": "abc123", "decision": "approve",
}]
assert output["result"]["confirmed"] == [{"id": 91}]
assert output["remaining"] == []
@pytest.mark.anyio
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
html = await dashboard()
@@ -254,3 +560,82 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
assert "if (result?.queued)" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "authoredOutbox.reconcileBackground(records)" in html
def test_screenshot_comment_admits_blob_to_indexeddb_without_putting_bytes_in_localstorage():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors=[];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox=createAuthoredOutbox({{
storage,getOwnerLogin:()=>'timmy',
backgroundSync:{{reconcile:async(items,lane)=>mirrors.push({{items,lane}}),requestSync:async()=>{{}}}},
}});
(async()=>{{
const blob=new Blob(['PRIVATE-IMAGE-BYTES'],{{type:'image/png'}});
const admission=await outbox.enqueueDurably({{
kind:'issue-comment',repository:'stackchain/dashboard',number:477,
body:'Broken at 320px',operationId:'comment-image-477',
attachment:{{filename:'phone.png',contentType:'image/png',blob}},
}});
const durable=mirrors[0].items[0].attachment;
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),
mirrored:{{lane:mirrors[0].lane,isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}}}}));
}})();
"""
output = run_node(script)
assert "PRIVATE-IMAGE-BYTES" not in output["local"]
local_item = json.loads(output["local"])["items"][0]
assert local_item["attachment"] == {
"filename": "phone.png", "contentType": "image/png", "stored": True
}
assert output["mirrored"] == {
"lane": "authored", "isBlob": True, "size": 19, "text": "PRIVATE-IMAGE-BYTES"
}
assert output["admission"]["durability"] == "background"
def test_screenshot_comment_failed_indexeddb_admission_is_not_accepted_locally():
script = f"""
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
reconcile:async()=>{{throw new Error('IndexedDB unavailable');}},requestSync:async()=>{{}},
}}}});
(async()=>{{
let error='';try{{await outbox.enqueueDurably({{kind:'issue-comment',repository:'o/r',number:1,
body:'Keep me',operationId:'image-op',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});}}
catch(caught){{error=caught.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output == {"error": "IndexedDB unavailable", "items": []}
def test_pull_screenshot_comment_persists_blob_only_in_indexeddb():
script = f"""
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const mirrors=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}},
}}}});
(async()=>{{const blob=new Blob(['PULL-PRIVATE-BYTES'],{{type:'image/webp'}});
await outbox.enqueueDurably({{kind:'pull-comment',repository:'stackchain/web',number:31,
body:'Mobile proof',operationId:'pull-image-31',
attachment:{{filename:'proof.webp',contentType:'image/webp',blob}}}});
const local=values.get('stackchain.authored-outbox.v1');const durable=mirrors[0][0].attachment;
process.stdout.write(JSON.stringify({{local,metadata:JSON.parse(local).items[0].attachment,
durable:{{isBlob:durable.blob instanceof Blob,text:await durable.blob.text()}}}}));
}})();
"""
output = run_node(script)
assert "PULL-PRIVATE-BYTES" not in output["local"]
assert output["metadata"] == {
"filename": "proof.webp", "contentType": "image/webp", "stored": True
}
assert output["durable"] == {"isBlob": True, "text": "PULL-PRIVATE-BYTES"}
@@ -0,0 +1,101 @@
import threading
import os
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
path = tmp_path / "available.sqlite3"
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
barrier = threading.Barrier(2)
results = []
def acquire(store):
barrier.wait()
results.append(store.try_acquire_refresh(lease_seconds=5))
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sum(owner is not None for owner in results) == 1
def test_published_catalog_and_metadata_are_visible_to_another_worker(tmp_path):
path = tmp_path / "available.sqlite3"
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = writer.try_acquire_refresh(lease_seconds=5)
writer.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
state = reader.load()
assert state.items == [{"repository": "stackchain/api", "number": 7}]
assert state.created_at == 100.0
assert state.retry_at is None
assert state.refreshing is False
def test_failed_refresh_backoff_is_shared_and_lease_is_released(tmp_path):
path = tmp_path / "available.sqlite3"
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = writer.try_acquire_refresh(lease_seconds=5)
writer.fail_refresh(owner, retry_at=105.0)
state = reader.load()
assert state.retry_at == 105.0
assert state.refreshing is False
assert reader.try_acquire_refresh(lease_seconds=5) is not None
def test_confirmed_claim_is_removed_for_every_worker(tmp_path):
path = tmp_path / "available.sqlite3"
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = writer.try_acquire_refresh(lease_seconds=5)
writer.publish(owner, items=[
{"repository": "stackchain/api", "number": 7},
{"repository": "stackchain/web", "number": 8},
])
writer.remove_claimed("stackchain/api", 7)
assert reader.load().items == [{"repository": "stackchain/web", "number": 8}]
def test_invalidation_clears_catalog_and_claim_filters(tmp_path):
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
owner = store.try_acquire_refresh(lease_seconds=5)
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
store.remove_claimed("stackchain/api", 7)
store.invalidate()
owner = store.try_acquire_refresh(lease_seconds=5)
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
assert store.load().items == [{"repository": "stackchain/api", "number": 7}]
def test_catalog_store_is_private(tmp_path):
path = tmp_path / "state" / "available.sqlite3"
AvailableIssueSnapshotStore(path)
assert os.stat(path.parent).st_mode & 0o777 == 0o700
assert os.stat(path).st_mode & 0o777 == 0o600
def test_cancelled_refresh_releases_its_lease_for_immediate_takeover(tmp_path):
path = tmp_path / "available.sqlite3"
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = first.try_acquire_refresh(lease_seconds=30)
first.release_refresh(owner)
assert second.try_acquire_refresh(lease_seconds=30) is not None
+888 -3
View File
@@ -6,7 +6,7 @@ import httpx
import pytest
from src import main
from src.views import dashboard
from tests.dashboard_bundle import dashboard
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
@@ -64,6 +64,324 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_closed_app_sync_delivers_desired_blocker_state():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{
id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false,
}};
const state={{calls:[],completed:[]}};
const store={{
claimNext:async()=>item?{{...item}}:null,
complete:async id=>{{state.completed.push(id);item=null;}},
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{
state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}});
if(url==='api/v1/background-identity')return{{login:'timmy'}};
return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}};
}};
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
"""
output = run_node(script)
assert output["state"]["calls"][1] == {
"url": "api/v1/repos/stackchain/dashboard/issues/17/blockers",
"method": "PATCH",
"key": "blocker-1",
"body": {"repository": "stackchain/api", "number": 9, "present": False},
}
assert output["state"]["completed"] == ["blocker-1"]
assert output["result"]["confirmed"][0]["dependencies"] == []
def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}};
const state={{completed:[],failed:[]}};
const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}};
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}};
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})();
"""
output = run_node(script)
assert output["state"]["completed"] == []
assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]]
assert output["result"]["attention"] == 1
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
}};
const state = {{calls:[],completed:0,released:0}};
const store = {{
claimNext:async()=>item ? {{...item}} : null,
update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{state.completed+=1;item=null;}},
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0,
}};
let uploadAttempts=0;
const fetchJson=async(url,options={{}})=>{{
let body=null;
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
else if(options.body)body=JSON.parse(options.body);
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url==='api/v1/background-identity')return{{login:'timmy'}};
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
if(url.endsWith('/attachments'))return{{markdown:'![phone.png](<https://forge.example/a/phone.png>)'}};
if(url.endsWith('/comments'))return{{id:91}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const afterFirst={{...item}};
const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Upload unavailable"
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
assert [call["url"] for call in output["state"]["calls"]].count(
"api/v1/repos/stackchain/dashboard/issues"
) == 1
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
assert output["state"]["calls"][-1]["body"] == {
"body": "![phone.png](<https://forge.example/a/phone.png>)"
}
assert output["state"]["completed"] == 1
assert output["second"]["confirmed"][0]["number"] == 469
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const blob=new Blob(['offline-binary'],{{type:'image/png'}});
const item={{id:'binary',operationId:'binary',ownerLogin:'timmy',repository:'o/r',title:'Visual',body:'',labelIds:[],
attachment:{{filename:'phone.png',contentType:'image/png',blob}}}};
let queued=true;
const observed={{}};
const store={{claimNext:async()=>queued?(queued=false,{{...item}}):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
if(url.endsWith('/issues'))return{{number:7}};
if(url.endsWith('/attachments')){{const file=options.body.get('file');Object.assign(observed,{{
isFormData:options.body instanceof FormData,contentTypeHeader:options.headers['Content-Type']||null,
filename:file.name,type:file.type,size:file.size,text:await file.text()
}});return{{markdown:'![phone](url)'}};}}
return{{id:8}};
}};
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(observed));}})();
"""
assert run_node(script) == {
"isFormData": True,
"contentTypeHeader": None,
"filename": "phone.png",
"type": "image/png",
"size": 14,
"text": "offline-binary",
}
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
const keys=[];let queued=true;
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:'![a](url)'}};return{{id:1}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
"""
keys = run_node(script)
assert len(keys) == 3
assert len(set(keys)) == 3
assert all(len(key) <= 128 for key in keys)
def test_capture_renews_claim_before_every_network_stage_and_fences_completion():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const item={{id:'capture',claimToken:'claim-7',operationId:'capture',ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
const events=[];let claimed=false;
const store={{
supportsClaimTokens:true,
claimNext:async()=>claimed?null:(claimed=true,item),
renew:async(id,token)=>{{events.push(['renew',id,token]);return item;}},
checkpoint:async(id,token,transform)=>{{events.push(['checkpoint',id,token]);transform(item);return true;}},
complete:async(id,token)=>events.push(['complete',id,token]),
release:async(id,token)=>events.push(['release',id,token]),
fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
events.push(['network',options.headers['Idempotency-Key']]);
if(url.endsWith('/issues'))return{{number:3}};
if(url.endsWith('/attachments'))return{{markdown:'![a](url)'}};
return{{id:4}};
}};
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(events));}})();
"""
events = run_node(script)
networks = [index for index, event in enumerate(events) if event[0] == "network"]
assert [events[index - 1][0] for index in networks] == ["renew", "renew", "renew"]
assert [events[index][1] for index in networks] == [
"capture", "capture:attachment", "capture:attachment-comment"
]
assert events[-1] == ["complete", "capture", "claim-7"]
assert all(event[2] == "claim-7" for event in events if event[0] in {"renew", "checkpoint"})
def test_screenshot_comment_retry_reuses_upload_checkpoint_and_posts_combined_body_once():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'message-image',operationId:'message-image',ownerLogin:'timmy',status:'queued',
kind:'issue-comment',repository:'stackchain/dashboard',number:477,body:'Broken at 320px',
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}}}};
const state={{calls:[],released:0,completed:0}};let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{state.completed++;item=null;}},release:async()=>{{state.released++;item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
let body=null;
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
else if(options.body)body=JSON.parse(options.body);
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
if(url.endsWith('/attachments'))return{{markdown:'![phone.png](https://forge.example/phone.png)'}};
if(commentAttempts++===0){{const error=new Error('Comment unavailable');error.status=503;throw error;}}
return{{id:91}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const checkpoint={{...item}};const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,checkpoint,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Comment unavailable"
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.png]")
assert sum(call["url"].endswith("/attachments") for call in output["state"]["calls"]) == 1
comments = [call for call in output["state"]["calls"] if call["url"].endswith("/comments")]
assert [call["key"] for call in comments] == ["message-image:comment"] * 2
assert comments[-1]["body"] == {
"body": "Broken at 320px\n\n![phone.png](https://forge.example/phone.png)"
}
assert output["state"]["completed"] == 1
assert output["second"]["confirmed"] == [{"id": 91}]
def test_pull_screenshot_retry_uploads_to_pull_and_reuses_checkpoint_before_one_comment():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'pull-image',operationId:'pull-image',ownerLogin:'timmy',status:'queued',
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body instanceof FormData?'multipart':JSON.parse(options.body)}});
if(url.endsWith('/attachments'))return{{markdown:'![proof.png](https://forge.example/proof.png)'}};
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}return{{id:77}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});try{{await sync.flush();}}catch(_error){{}}
const checkpoint=item.attachmentMarkdown;const result=await sync.flush();
process.stdout.write(JSON.stringify({{calls,checkpoint,result}}));}})();
"""
output = run_node(script)
assert output["checkpoint"].startswith("![proof.png]")
assert [call["url"] for call in output["calls"]] == [
"api/v1/repos/stackchain/web/pulls/31/attachments",
"api/v1/repos/stackchain/web/pulls/31/comments",
"api/v1/repos/stackchain/web/pulls/31/comments",
]
assert [call["key"] for call in output["calls"]] == [
"pull-image:attachment", "pull-image:comment", "pull-image:comment"
]
assert output["calls"][-1]["body"] == {
"body": "Mobile proof\n\n![proof.png](https://forge.example/proof.png)"
}
assert output["result"]["confirmed"] == [{"id": 77}]
def test_pull_screenshot_comment_timeout_replays_one_upload_with_durable_stage_identity():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
let item={{id:'pull-timeout',operationId:'pull-timeout',ownerLogin:'timmy',status:'queued',
kind:'pull-comment',repository:'stackchain/web',number:31,body:'Mobile proof',
attachment:{{filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}}}};
const calls=[];let commentAttempts=0;
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,fn)=>{{item=fn(item);}},
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};
calls.push({{url,key:options.headers?.['Idempotency-Key']}});
if(url.endsWith('/attachments'))return{{markdown:'![proof.png](https://forge.example/proof.png)'}};
if(commentAttempts++===0)return new Promise(()=>{{}});return{{id:78}};
}};
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:5}});
let timedOut='';try{{await sync.flush();}}catch(error){{timedOut=error.message;}}
const checkpoint=item.attachmentMarkdown;const replay=await sync.flush();
process.stdout.write(JSON.stringify({{calls,timedOut,checkpoint,replay}}));}})();
"""
output = run_node(script)
assert output["timedOut"] == "Background request timed out."
assert output["checkpoint"].startswith("![proof.png]")
assert [call["key"] for call in output["calls"]] == [
"pull-timeout:attachment", "pull-timeout:comment", "pull-timeout:comment"
]
assert sum(call["url"].endswith("/attachments") for call in output["calls"]) == 1
assert output["replay"]["confirmed"] == [{"id": 78}]
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const item = {{
id:'capture-start',operationId:'capture-start',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',title:'Resume',body:'',labelIds:[],
completionIntent:'create-and-start',
}};
let queued = item;
const state = {{completed:[]}};
const store = {{
claimNext:async () => queued ? (queued=null,{{...item}}) : null,
complete:async (id, deliveredIssue) => state.completed.push({{id,deliveredIssue}}),
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson = async url => url === 'api/v1/background-identity' ? {{login:'timmy'}} :
{{repository:'stackchain/dashboard',number:389,title:'Resume'}};
(async()=>{{
await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify(state));
}})();
"""
output = run_node(script)
assert output["completed"] == [{
"id": "capture-start",
"deliveredIssue": {
"repository": "stackchain/dashboard", "number": 389, "title": "Resume"
},
}]
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -171,6 +489,114 @@ const fetchJson = async (url, options = {{}}) => {{
assert output["result"]["confirmed"] == [{"id": 42}]
def test_closed_app_sync_delivers_pull_review_with_stable_identity_and_full_payload():
authored = {
"id": "review-op", "operationId": "review-op", "ownerLogin": "timmy", "status": "queued",
"kind": "pull-review", "repository": "stackchain/web", "number": 8,
"body": "Looks good", "decision": "approve", "expectedHeadSha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)}; const calls=[];
const store = {{
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/web/pulls/8/review"
assert mutation["options"]["headers"]["Idempotency-Key"] == "review-op"
assert json.loads(mutation["options"]["body"]) == {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
assert output["result"]["confirmed"] == [{"id": 45}]
assert output["result"]["receipts"] == [{
"id": "review-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/review/stackchain/web/8",
}]
def test_closed_app_sync_holds_consequential_review_for_foreground_authorization():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const item={{id:'review-op',operationId:'review-op',ownerLogin:'timmy',status:'queued',
kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
decision:'approve',expectedHeadSha:'abc123',comments:[]}};
const state={{authorized:[],failed:[],released:[],completed:[]}};let claimed=false;
const store={{
claimNext:async()=>claimed?null:(claimed=true,item),
authorization:async(id,error)=>state.authorized.push({{id,error}}),
fail:async(...args)=>state.failed.push(args),release:async id=>state.released.push(id),
complete:async id=>state.completed.push(id),countBlocked:async()=>0,
}};
const fetchJson=async url=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
const error=new Error('Fresh authorization required');error.status=428;
error.code='step_up_required';throw error;
}};
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{state,result}}));}})();
"""
output = run_node(script)
assert output["state"] == {
"authorized": [{"id": "review-op", "error": "Fresh authorization required"}],
"failed": [], "released": [], "completed": [],
}
assert output["result"]["attention"] == 0
assert output["result"]["authorization"] == 1
assert output["result"]["receipts"] == [{
"id": "review-op", "status": "authorization", "kind": "message",
"route": "#/my-work/review/stackchain/web/8",
}]
def test_closed_app_sync_leaves_issue_closure_awaiting_foreground_authorization():
records = [
{
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
},
{
"id": "comment-op", "operationId": "comment-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-comment", "repository": "stackchain/dashboard", "number": 27, "body": "Still deliver",
},
]
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map({json.dumps([[item["id"], item] for item in records])});let tail=Promise.resolve();const calls=[];
const transaction=work=>{{const run=tail.then(()=>work({{
get:async id=>records.get(id),getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
const fetchJson=async(url,options={{}})=>{{
calls.push({{url,options}});
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};
}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{calls,result,snapshot:await store.snapshot()}}));
}})();
"""
output = run_node(script)
mutations = output["calls"][1:]
assert [call["url"] for call in mutations] == [
"api/v1/repos/stackchain/dashboard/issues/27/comments"
]
assert output["result"]["confirmed"] == [{"id": 45}]
by_id = {item["id"]: item for item in output["snapshot"]}
assert by_id["close-op"]["status"] == "queued"
assert by_id["comment-op"]["status"] == "sent"
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -194,6 +620,114 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_blob():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.reconcile([{{
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
}}]);
await store.reconcile([
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
]);
const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
}})();
"""
output = run_node(script)
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map([
['first',{{id:'first',attachment:{{filename:'one.png',contentType:'image/png',data:'first-bytes'}}}}],
['second',{{id:'second',attachment:{{filename:'two.png',contentType:'image/png',data:'second-bytes'}}}}],
]);
const calls=[];
const transaction=work=>work({{
get:async id=>{{calls.push(['get',id]);return records.get(id);}},
getAll:async()=>{{calls.push(['getAll']);return [...records.values()];}},
put:async()=>{{}},delete:async()=>{{}},
}});
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
const hydrated=await store.get('second');
process.stdout.write(JSON.stringify({{hydrated,calls}}));
}})();
"""
output = run_node(script)
assert output["hydrated"]["attachment"]["data"] == "second-bytes"
assert output["calls"] == [["get", "second"]]
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:'![a](url)'}}));
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state={{opens:0,closes:0}};
const indexedDB={{lastDb:null,open:()=>{{
state.opens += 1;
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
const db={{
objectStoreNames:{{contains:()=>true}},
close:()=>{{state.closes += 1;}},
transaction:()=>{{
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
}})}};
setTimeout(()=>tx.oncomplete?.(),0);
return tx;
}},
}};
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
}}}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
await store.snapshot();
indexedDB.lastDb.onversionchange?.();
await store.snapshot();
process.stdout.write(JSON.stringify(state));
}})();
"""
output = run_node(script)
assert output == {"opens": 2, "closes": 1}
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -326,6 +860,246 @@ const fetchJson = async url => {{
assert output["error"] == "Gitea unavailable"
def test_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
const store = {{
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
complete: async id => state.completed.push(id),
release: async id => {{state.released.push(id); state.queued=true;}},
fail: async () => {{}}, countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
state.keys.push(options.headers['Idempotency-Key']);
state.attempts += 1;
if (state.attempts === 1) return new Promise(() => {{}});
return {{repository:'o/r',number:9}};
}};
(async () => {{
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
let firstError='';
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Background request timed out."
assert output["state"]["released"] == ["capture-timeout"]
assert output["state"]["completed"] == ["capture-timeout"]
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
assert output["second"]["confirmed"][0]["number"] == 9
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const items=[
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
];
const state={{released:[],closed:0,mutations:0}};
let mutationStarted;
const started=new Promise(resolve => mutationStarted=resolve);
const store={{
claimBatch:async()=>items.map(item=>({{...item}})),
complete:async()=>{{}},
release:async id=>state.released.push(id),
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
}};
const fetchJson=async url=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
sync.flush().catch(()=>{{}});
await started;
const outcome=await Promise.race([
sync.purge().then(()=> 'purged'),
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
]);
process.stdout.write(JSON.stringify({{state,outcome}}));
process.exit(0);
}})();
"""
output = run_node(script)
assert output["outcome"] == "purged"
assert output["state"] == {
"released": ["capture-purge-1", "capture-purge-2"],
"closed": 1,
"mutations": 1,
}
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const items = [
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
];
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
const store = {{
claimBatch: async (owner, limit) => items.slice(0, limit),
complete: async id => state.completed.push(id),
release: async id => state.released.push(id),
fail: async () => {{}}, countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const key = options.headers['Idempotency-Key'];
state.attempted.push(key); state.active += 1;
state.maxActive = Math.max(state.maxActive, state.active);
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
state.active -= 1;
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
return {{id:key,number:7}};
}};
(async () => {{
let error;
try {{ await createBackgroundIssueSync({{
store,fetchJson,maxConcurrency:3,batchSize:10,
batch: async work => {{ state.batches += 1; return work(); }},
}}).flush(); }}
catch (caught) {{ error=caught.message; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output["state"]["maxActive"] == 3
assert output["state"]["batches"] == 1
assert sorted(output["state"]["attempted"]) == [
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
]
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
assert sorted(output["state"]["completed"]) == [
"issue-1", "issue-2", "message-1", "message-2"
]
assert output["state"]["released"] == ["issue-bad"]
assert output["error"] == "Temporary outage"
def test_bounded_flush_claims_just_in_time_and_keeps_unadmitted_records_queued():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
get:async id=>records.get(id),
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
let allow=false;const waiting=[];const attempted=[];
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
attempted.push(options.headers['Idempotency-Key']);
if(!allow)await new Promise(resolve=>waiting.push(resolve));
return{{number:attempted.length}};
}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,createToken:(()=>{{let n=0;return()=>`token-${{++n}}`;}})()}});
await store.reconcile(Array.from({{length:4}},(_,i)=>({{id:`issue-${{i}}`,operationId:`issue-${{i}}`,ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Issue',labelIds:[]}})),'issue');
await store.reconcile(Array.from({{length:2}},(_,i)=>({{id:`message-${{i}}`,operationId:`message-${{i}}`,ownerLogin:'timmy',status:'queued',kind:'issue-comment',repository:'o/r',number:i,body:'Reply'}})),'authored');
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2,batchSize:5}});
const flushing=sync.flush();
while(waiting.length<2)await new Promise(resolve=>setTimeout(resolve,0));
const during=await store.snapshot();
allow=true;waiting.splice(0).forEach(resolve=>resolve());
await flushing;
const after=await store.snapshot();
process.stdout.write(JSON.stringify({{attempted,during,after}}));
}})();
"""
output = run_node(script)
assert [item["id"] for item in output["during"] if item["status"] == "sending"] == [
"issue-0", "message-0"
]
assert [item["id"] for item in output["during"] if item["status"] == "queued"] == [
"issue-1", "issue-2", "issue-3", "message-1"
]
assert output["attempted"] == [
"issue-0", "message-0", "issue-1", "message-1", "issue-2"
]
unadmitted = next(item for item in output["after"] if item["id"] == "issue-3")
assert unadmitted["status"] == "queued"
assert "claimToken" not in unadmitted
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const items = Array.from({{length:5}}, (_, index) => ({{
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
repository:'o/r',title:'Item',body:'',labelIds:[],
}}));
const state = {{attempted:[],released:[],completed:[]}};
const store = {{
claimBatch: async () => items,
complete: async id => state.completed.push(id),
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
await new Promise(resolve => setTimeout(resolve, 20));
return {{number:1}};
}};
(async()=>{{
let error;
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output["state"]["attempted"] == ["item-0", "item-1"]
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
assert output["state"]["completed"] == ["item-1"]
assert output["error"] == {"message": "Authentication required", "status": 401}
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100,claimMs:5000}});
await store.reconcile([
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
], 'issue');
await store.reconcile([
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
], 'authored');
const planned=await store.planBatch('timmy', 4);
process.stdout.write(JSON.stringify({{planned,snapshot:await store.snapshot()}}));
}})();
"""
output = run_node(script)
assert output["planned"] == ["issue-1", "message-1", "issue-2", "message-2"]
assert all(item["status"] == "queued" for item in output["snapshot"])
assert all("claimUntil" not in item for item in output["snapshot"])
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -355,6 +1129,31 @@ const fetchJson = async url => {{
assert output["result"]["attention"] == 1
def test_uncertain_background_delivery_preserves_verification_state():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let claimed=false; const state={{failed:[]}};
const store={{
claimNext:async()=>claimed?null:(claimed=true,{{id:'maybe',operationId:'maybe',ownerLogin:'timmy',repository:'o/r',title:'Maybe',labelIds:[]}}),
fail:async(id,message,deliveryState)=>state.failed.push({{id,message,deliveryState}}),
release:async()=>{{}}, countBlocked:async()=>0,
}};
const fetchJson=async url=>{{
if(url==='api/v1/background-identity') return {{login:'timmy'}};
const error=new Error('Verify first'); error.status=422; error.code='delivery_uncertain'; throw error;
}};
(async()=>{{
await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify(state));
}})();
"""
output = run_node(script)
assert output["failed"] == [
{"id": "maybe", "message": "Verify first", "deliveryState": "uncertain"}
]
def test_issue_store_atomically_grants_one_delivery_claim():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -383,6 +1182,47 @@ const transaction = work => {{
assert output["records"][0]["claimUntil"] == 6000
def test_issue_store_renews_and_fences_claim_mutations_with_unique_tokens():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();let timestamp=100;
const transaction=work=>{{const run=tail.then(()=>work({{
get:async id=>records.get(id),
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const tokens=['claim-a','claim-b'];
const store=createBackgroundIssueSync.createIssueSyncStore({{
transaction,now:()=>timestamp,claimMs:30,createToken:()=>tokens.shift(),
}});
await store.reconcile([{{id:'lease',ownerLogin:'timmy',status:'queued'}}]);
const first=await store.claimNext('timmy');
timestamp=120;
const renewed=await store.renew('lease',first.claimToken);
timestamp=151;
const second=await store.claimNext('timmy');
const staleComplete=await store.complete('lease',first.claimToken);
const staleRelease=await store.release('lease',first.claimToken);
const staleFail=await store.fail('lease',first.claimToken,'old failure');
const currentComplete=await store.complete('lease',second.claimToken,{{number:9}});
process.stdout.write(JSON.stringify({{first,renewed,second,staleComplete,staleRelease,staleFail,currentComplete,record:records.get('lease')}}));
}})();
"""
output = run_node(script)
assert output["first"]["claimToken"] == "claim-a"
assert output["first"]["claimUntil"] == 130
assert output["renewed"]["claimUntil"] == 150
assert output["second"]["claimToken"] == "claim-b"
assert output["second"]["claimUntil"] == 181
assert output["staleComplete"] is False
assert output["staleRelease"] is False
assert output["staleFail"] is False
assert output["currentComplete"] is True
assert output["record"]["status"] == "sent"
def test_foreground_and_worker_race_still_posts_one_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -453,8 +1293,8 @@ const transaction=work=>{{const run=tail.then(()=>work({{
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
await store.claimNext('timmy');
await store.complete('done');
const claimed=await store.claimNext('timmy');
await store.complete('done', claimed.claimToken);
const replay=await store.claimNext('timmy');
const snapshot=await store.snapshot();
process.stdout.write(JSON.stringify({{replay,snapshot}}));
@@ -491,6 +1331,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{
assert "error" not in output
def test_resume_after_purge_reopens_background_delivery_with_fresh_identity():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state={{closed:0,identityLookups:0,mutations:0,keys:[]}};
let queued={{
id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard',
title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login',
}};
const store={{
close:async()=>{{state.closed+=1;}},
claimBatch:async login=>{{
if(queued?.ownerLogin!==login)return [];
const claimed=queued;queued=null;return [claimed];
}},
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}}
state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']);
return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
await sync.purge();
const blocked=await sync.flush();
await sync.resume();
const delivered=await sync.flush();
process.stdout.write(JSON.stringify({{state,blocked,delivered}}));
}})();
"""
output = run_node(script)
assert output["state"] == {
"closed": 1,
"identityLookups": 1,
"mutations": 1,
"keys": ["op-after-login"],
}
assert output["blocked"]["login"] == ""
assert output["delivered"]["login"] == "timmy"
assert output["delivered"]["confirmed"] == [
{"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"}
]
@pytest.mark.anyio
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
calls = 0
+11
View File
@@ -15,3 +15,14 @@ def test_ci_installs_declared_requirements_before_tests():
install = text.index("pip install -r requirements.txt")
tests = text.index("python3 -m pytest tests/ -q")
assert install < tests
def test_release_promotion_waits_for_tests_and_bundle():
text = WORKFLOW.read_text()
release = text[text.index(" release-candidate:") :]
assert "needs: [lint, build-release]" in release
assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
+72 -4
View File
@@ -4,10 +4,13 @@ from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
from tests.dashboard_bundle import dashboard_bundle_text
FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
class ScriptSourceParser(HTMLParser):
@@ -41,7 +44,7 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
def test_command_script_resolves_inside_dashboard_subpath():
parser = ScriptSourceParser()
parser.feed((FRONTEND / "index.html").read_text())
parser.feed(dashboard_bundle_text())
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
assert urljoin(
@@ -152,7 +155,7 @@ if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys sho
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
html = (FRONTEND / "index.html").read_text()
html = dashboard_bundle_text()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
@@ -221,7 +224,7 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
def test_remote_search_selection_opens_native_preview_without_navigation():
html = (FRONTEND / "index.html").read_text()
html = dashboard_bundle_text()
run_item = html.split("function runCommandItem(item)", 1)[1].split(
"function renderCommands", 1
@@ -233,8 +236,73 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = (FRONTEND / "index.html").read_text()
html = dashboard_bundle_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
assert "openPreviewIssueInMyWork" in html
def test_search_preview_offers_assign_and_start_for_eligible_issues():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="start-search-result"' in html
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
assert "const searchAssignAndStart = createAssignAndStart({" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
script = f"""
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
const listeners = new Map();
const viewport = {{
height: 430, offsetTop: 17,
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: (name, fn) => {{ if (listeners.get(name) === fn) listeners.delete(name); }},
}};
const values = new Map();
const palette = {{ style: {{
setProperty: (name, value) => values.set(name, value),
removeProperty: name => values.delete(name),
}} }};
const results = {{ scrollTop: 73 }};
const controller = createMobileSearchViewport({{
palette, results, viewport, mediaQuery: {{ matches: true }},
schedule: fn => fn(),
}});
controller.open();
if (values.get('--search-viewport-top') !== '17px') throw new Error('viewport offset was not applied');
if (values.get('--search-viewport-height') !== '430px') throw new Error('viewport height was not applied');
if (listeners.size !== 2) throw new Error('viewport listeners were not attached once');
controller.rememberScroll();
results.scrollTop = 0;
controller.restoreScroll();
if (results.scrollTop !== 73) throw new Error('result scroll position was not restored');
controller.open();
if (listeners.size !== 2) throw new Error('duplicate viewport listeners were attached');
controller.close();
if (listeners.size !== 0 || values.size !== 0) throw new Error('viewport state leaked after close');
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_mobile_search_is_a_keyboard_safe_workspace_wired_to_preview_history():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'class="cmd-palette-header"' in html
assert 'id="close-command-palette"' in html
assert 'style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"' not in html
assert 'src="static/mobile-search-viewport.js"' in html
assert "height:var(--search-viewport-height,100dvh)" in css
assert "top:var(--search-viewport-top,0px)" in css
assert "#cmd-results { flex:1; min-height:0; overflow-y:auto;" in css
assert "mobileSearchViewport.rememberScroll()" in html
assert "mobileSearchViewport.restoreScroll()" in html
assert "mobileSearchViewport.open()" in html
assert "mobileSearchViewport.close()" in html
+306
View File
@@ -0,0 +1,306 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_comment_and_next_posts_then_completes_current_today_item_once():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
let finishPost;
const controller = createCommentNext({{
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
queue: () => {{ throw new Error('must not queue'); }},
canQueue: () => false,
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
const first = controller.submit(item, 'Handoff ready');
const second = controller.submit(item, 'Handoff ready');
if (first !== second) throw new Error('submission was not single-flight');
finishPost({{id:7, body:'Handoff ready'}});
(async () => {{
const result = await first;
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 7, "body": "Handoff ready"},
"completed": True,
},
"calls": ["post:429:Handoff ready", "complete:429"],
"busy": False,
}
def test_comment_and_next_advances_after_durable_offline_admission():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = new Error('offline'); retryable.status = 503;
const controller = createCommentNext({{
post: () => Promise.reject(retryable),
canQueue: error => error.status === 503,
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
process.stdout.write(JSON.stringify({{result, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{
"kind": "pull-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "Please review",
"operationId": "op-12",
},
"complete:12",
],
}
def test_update_reply_and_next_preserves_notification_identity_online_and_offline():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = Object.assign(new Error('offline'), {{status:503}});
let offline = false;
const controller = createCommentNext({{
queueKind: 'update-reply',
post: (item, body, operationId) => {{
calls.push({{post:[item.notification_id, body, operationId]}});
return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}});
}},
canQueue: () => true,
queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }},
complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }},
}});
(async () => {{
const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}};
const posted = await controller.submit(item, 'Online reply', 'reply-91-a');
offline = true;
const saved = await controller.submit(item, 'Offline reply', 'reply-91-b');
process.stdout.write(JSON.stringify({{posted, saved, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"posted": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 17},
"completed": True,
},
"saved": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{"post": [91, "Online reply", "reply-91-a"]},
{"complete": 91},
{"post": [91, "Offline reply", "reply-91-b"]},
{
"queue": {
"kind": "update-reply",
"notificationId": 91,
"body": "Offline reply",
"operationId": "reply-91-b",
}
},
{"complete": 91},
],
}
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
let operationId = '';
let queued;
const controller = createCommentNext({{
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
canQueue: () => true,
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
complete: () => true,
}});
(async () => {{
const result = await controller.submit(
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
);
process.stdout.write(JSON.stringify({{result, queued}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["result"]["accepted"] is True
assert output["result"]["delivery"] == "saved"
assert output["queued"]["operationId"] == "post-attempt-id"
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const controller = createCommentNext({{
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
accept: item => calls.push(`clear:${{item.number}}`),
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
process.stdout.write(JSON.stringify(calls));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const failures = [];
const permanent = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
canQueue: () => false,
queue: () => Promise.resolve({{durable:true}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const notDurable = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
canQueue: () => true,
queue: () => Promise.resolve({{durable:false, background:false}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const cannotComplete = createCommentNext({{
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
complete: () => false,
}});
(async () => {{
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
}}
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
process.stdout.write(JSON.stringify({{failures, partial}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
"partial": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 9},
"completed": False,
},
}
def test_today_checkpoint_only_matches_the_current_session_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'issue', repository:'r', number:1}},
{{kind:'pull', repository:'r', number:2}},
];
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
}});
session.start();
process.stdout.write(JSON.stringify({{
current: session.checkpointed(items[0]),
other: session.checkpointed(items[1]),
generic: session.checkpointed(),
}}));
"""
assert json.loads(run_node(script)) == {
"current": True,
"other": False,
"generic": True,
}
def test_comment_and_next_can_admit_a_complete_screenshot_message_before_advancing():
script = f"""
const createCommentNext=require({json.dumps(str(COMMENT_NEXT))});
const calls=[];
const controller=createCommentNext({{
post:()=>{{throw new Error('must not post');}},canQueue:()=>true,
queue:async message=>{{calls.push({{queue:message}});return{{item:{{id:'queued'}},background:true}};}},
accept:item=>calls.push({{accept:item.number}}),
complete:item=>{{calls.push({{complete:item.number}});return true;}},
}});
(async()=>{{const item={{kind:'issue',repository:'stackchain/dashboard',number:477}};
const result=await controller.admit(item,{{kind:'issue-comment',repository:item.repository,number:item.number,
body:'Mobile evidence',operationId:'image-op',attachment:{{filename:'phone.png',contentType:'image/png',data:'abc'}}}});
process.stdout.write(JSON.stringify({{result,calls}}));
}})();
"""
output = json.loads(run_node(script))
assert output["result"] == {
"accepted": True, "delivery": "queued", "background": True, "completed": True
}
assert output["calls"][0]["queue"]["attachment"]["data"] == "abc"
assert output["calls"][1:] == [{"accept": 477}, {"complete": 477}]
@pytest.mark.anyio
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
html = await dashboard()
assert 'id="send-issue-comment-next"' in html
assert 'id="send-pull-comment-next"' in html
assert html.count('>Comment &amp; next</button>') == 2
assert "setCommentNextVisibility('issue')" in html
assert "setCommentNextVisibility('pull')" in html
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.comment-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_unread_update_offers_reply_mark_read_and_next_independent_of_today():
html = await dashboard()
assert 'id="send-update-reply-read-next"' in html
assert '>Reply, mark read &amp; next</button>' in html
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
assert "markRead: markNotificationRead" in html
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" in html
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-v87" in worker
+358
View File
@@ -4,6 +4,7 @@ from pathlib import Path
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
def run_node(script: str) -> dict:
@@ -63,3 +64,360 @@ const poller = createContextPoller({{
"callsWhileHidden": 1,
"callsAfterResume": 2,
}
def test_context_poller_sends_revisions_and_merges_changed_sections():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const requested = [];
const rendered = [];
const responses = [
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
freshness: {{ age_seconds: 1 }} }},
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
];
const poller = createContextPoller({{
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
onSnapshot: (snapshot, changed) => rendered.push({{
context: snapshot.context.user.login,
event: snapshot.events[0].id,
age: snapshot.freshness.age_seconds,
changed,
}}),
onError: error => {{ throw error; }},
setTimer: () => 1,
clearTimer: () => {{}},
}});
(async () => {{
await poller.refresh();
await poller.refresh();
await poller.refresh();
process.stdout.write(JSON.stringify({{ requested, rendered }}));
}})();
"""
assert run_node(script) == {
"requested": [
{},
{"context": 1, "events": 1, "notifications": 1},
{"context": 1, "events": 2, "notifications": 1},
],
"rendered": [
{"context": "timmy", "event": 1, "age": 0,
"changed": ["context", "events", "notifications"]},
{"context": "timmy", "event": 2, "age": 1,
"changed": ["events"]},
{"context": "timmy", "event": 2, "age": 2, "changed": []},
],
}
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 60,
sections: {{
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [60000]}
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 60,
sections: {{
context: {{ degraded: false, age_seconds: 2 }},
events: {{ degraded: false, age_seconds: 5 }},
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [3000]}
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
sections: {{
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [8000]}
def test_context_poller_honors_http_retry_after_delay():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const errors = [];
const unavailable = new Error('HTTP 503');
unavailable.retryAfterMs = 45000;
const poller = createContextPoller({{
fetchContext: () => Promise.reject(unavailable),
onSnapshot: () => {{}},
onError: error => errors.push(error.message),
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays, errors }}));
}})();
"""
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
def test_retry_after_parser_accepts_bounded_delta_seconds():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
process.stdout.write(JSON.stringify({{
valid: createContextPoller.retryAfterMs('45'),
zero: createContextPoller.retryAfterMs('0'),
invalid: createContextPoller.retryAfterMs('later'),
excessive: createContextPoller.retryAfterMs('999999'),
}}));
"""
assert run_node(script) == {
"valid": 45000,
"zero": None,
"invalid": None,
"excessive": 300000,
}
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: () => {{
calls += 1;
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
}},
onSnapshot: () => {{}},
onError: () => {{}},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
maxBackoffMs: 60000,
}});
(async () => {{
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
}
def test_live_fetch_passes_retry_after_to_the_poller_error():
source = DASHBOARD.read_text()
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
assert "error.retryAfterMs" in source
def test_live_revision_query_preserves_bounded_opaque_tokens():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const query = createContextPoller.buildRevisionQuery({{
context: '0123456789abcdef.12',
events: 'fedcba9876543210.3',
notifications: 'not-a-token',
extra: '0123456789abcdef.1',
}});
process.stdout.write(JSON.stringify({{ query }}));
"""
assert run_node(script) == {
"query": "context_revision=0123456789abcdef.12&events_revision=fedcba9876543210.3"
}
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const timers = [];
const deadlineTimers = [];
const errors = [];
const snapshots = [];
const signals = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: (revisions, options) => {{
calls += 1;
signals.push(options.signal);
if (calls === 1) return new Promise(() => {{}});
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
}},
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
onError: error => errors.push({{ name: error.name, message: error.message }}),
setTimer: (callback, delay) => {{
const timer = {{ callback, delay, cancelled: false }};
timers.push(timer);
return timer;
}},
clearTimer: timer => {{ timer.cancelled = true; }},
setDeadlineTimer: (callback, delay) => {{
const timer = {{ callback, delay, cancelled: false }};
deadlineTimers.push(timer);
return timer;
}},
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
intervalMs: 8000,
timeoutMs: 25,
}});
(async () => {{
const first = poller.start();
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
deadline.callback();
await first;
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
retry.callback();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{
calls,
firstAborted: signals[0].aborted,
errors,
snapshots,
}}));
}})();
"""
assert run_node(script) == {
"calls": 2,
"firstAborted": True,
"errors": [
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
],
"snapshots": ["timmy"],
}
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
let resolveFirst;
let firstSignal;
const rendered = [];
const errors = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: (revisions, options) => {{
calls += 1;
if (calls === 1) {{
firstSignal = options.signal;
return new Promise(resolve => {{ resolveFirst = resolve; }});
}}
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
}},
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
onError: error => errors.push(error.name),
setTimer: () => 1,
clearTimer: () => {{}},
setDeadlineTimer: () => 1,
clearDeadlineTimer: () => {{}},
}});
(async () => {{
const stale = poller.start();
const fresh = poller.refresh({{ force: true }});
await fresh;
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
await stale;
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{
calls,
firstAborted: firstSignal.aborted,
rendered,
errors,
}}));
}})();
"""
assert run_node(script) == {
"calls": 2,
"firstAborted": True,
"rendered": ["fresh"],
"errors": [],
}
+127
View File
@@ -0,0 +1,127 @@
import json
import subprocess
from pathlib import Path
CREATE_AND_START = Path(__file__).parents[1] / "frontend" / "create-and-start.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
script = f"""
const fs = require('fs');
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
const calls = [];
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
const flow = createCreateAndStart({{
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const result = flow.complete(issue);
flow.complete(issue);
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
}}
"""
assert run_node(script) == {
"available": True,
"result": "started",
"calls": [
"add",
"add:issue:stackchain/dashboard:387:",
"refresh",
"flush",
"warm",
"start:387",
"announce:Created, added to Today, and ready to work.",
],
}
def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_shell():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert '<button id="create-and-start-issue" type="submit">Create &amp; start</button>' in html
assert '<script src="static/create-and-start.js"></script>' in html
assert "const createAndStart = createCreateAndStart({" in dashboard
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
assert "createAndStart.complete(created)" in dashboard
assert ".create-issue-actions" in css and "grid-template-columns" in css
assert "BASE + 'static/create-and-start.js'" in worker
def test_create_and_start_intent_is_durable_and_has_a_one_tap_relaunch_continuation():
dashboard = DASHBOARD.read_text()
assert "completionIntent: 'create-and-start'" in dashboard
assert "result.completions || []" in dashboard
assert "(result.completions || []).forEach(completion =>" in dashboard
assert "issueOutbox.pendingCompletions(activeFlushLogin)" in dashboard
assert "issueOutbox.completeIntent(completion.id, activeFlushLogin)" in dashboard
assert 'class="draft-continue"' in dashboard
assert "Continue created work" in dashboard
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
script = f"""
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
const calls = [];
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:388}};
const flow = createCreateAndStart({{
todayWork: {{limit:5, read:()=>[], identity:()=> 'issue:stackchain/dashboard:388:', add:()=> 'added'}},
todaySync: {{enqueue:()=>false, flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:()=>calls.push('start'),
announce:message=>calls.push(message),
}});
process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}}));
"""
assert run_node(script) == {
"result": "sync-unavailable",
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
}
def test_create_and_start_can_resume_after_today_sync_admission_recovers():
script = f"""
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
const calls=[];let admitted=false;let added=false;
const issue={{kind:'issue',repository:'stackchain/dashboard',number:389}};
const flow=createCreateAndStart({{
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:stackchain/dashboard:389:',add:()=>{{
const outcome=added?'exists':'added';added=true;return outcome;
}}}},
todaySync:{{enqueue:()=>admitted,flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
announce:message=>calls.push(message),
}});
const first=flow.complete(issue);
admitted=true;
const second=flow.complete(issue);
process.stdout.write(JSON.stringify({{first,second,calls}}));
"""
output = run_node(script)
assert output["first"] == "sync-unavailable"
assert output["second"] == "started"
assert output["calls"][-5:] == [
"refresh", "flush", "warm", "start",
"Created, added to Today, and ready to work.",
]
File diff suppressed because it is too large. Load diff
+732 -8
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
@@ -14,11 +14,13 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, timers: [], now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
getItem(key) {{ return this.values.get(key) || null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
@@ -26,15 +28,40 @@ const boundary = createSessionBoundary({{
origin: 'https://forge.example',
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
if (state.failFetch) throw new Error('offline');
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
indexedDB: {{ deleteDatabase: name => {{
state.deletedDatabases.push(name);
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
setTimeout(() => {{
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
}}, 20);
return request;
}} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
MessageChannel: class {{ constructor() {{
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
this.port1 = first; this.port2 = second;
}} }},
location: {{
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
now: () => state.now,
setTimer: (callback, delay) => {{ state.leaseDelay = delay; state.timers.push({{callback, delay}}); return state.timers.length; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
@@ -42,6 +69,160 @@ const boundary = createSessionBoundary({{
return json.loads(result.stdout)
def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
let aborted = false;
const boundary = createSessionBoundary({{
cookie: () => '',
origin: 'https://forge.example',
base: '/dashboard/',
requestTimeoutMs: 10,
fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{
options.signal?.addEventListener('abort', () => {{ aborted = true; }});
setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80);
}}),
location: {{ replace: () => {{}} }},
}});
(async () => {{
const started = Date.now();
try {{ await boundary.fetch('/dashboard/api/v1/context'); }}
catch (error) {{
process.stdout.write(JSON.stringify({{
name:error.name, message:error.message, method:error.method,
outcome:error.outcome, aborted, elapsed:Date.now()-started,
}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output["name"] == "TimeoutError"
assert output["message"] == "Request timed out. Try again."
assert output["method"] == "GET"
assert output["outcome"] == "unknown"
assert output["aborted"] is True
assert output["elapsed"] < 70
def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const caller = new AbortController();
let requestAborted = false;
const boundary = createSessionBoundary({{
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
fetchImpl: (_url, options = {{}}) => new Promise(() => {{
options.signal.addEventListener('abort', () => {{ requestAborted = true; }});
}}),
location: {{ replace: () => {{}} }},
}});
(async () => {{
const started = Date.now();
const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}});
setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10);
try {{ await pending; }} catch (error) {{
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output["name"] == "AbortError"
assert output["message"] == "Superseded"
assert output["source"] == "caller"
assert output["requestAborted"] is True
assert output["elapsed"] < 100
def test_request_object_abort_signal_is_composed_with_the_api_deadline():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const caller = new AbortController();
const boundary = createSessionBoundary({{
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
}});
(async () => {{
const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}});
const pending = boundary.fetch(request);
setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5);
try {{ await pending; }} catch (error) {{
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
assert json.loads(result.stdout) == {
"name": "AbortError",
"message": "Request superseded",
"source": "caller",
}
def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const boundary = createSessionBoundary({{
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
}});
(async () => {{
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }}
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output == {
"name": "TimeoutError",
"message": "Request timed out. Refresh to verify the outcome before retrying.",
"method": "PATCH",
"safeToRetry": False,
}
def test_fresh_authorization_request_uses_the_same_deadline_boundary():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
let calls = 0;
const boundary = createSessionBoundary({{
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
fetchImpl: () => {{
calls += 1;
if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}}));
return new Promise(() => {{}});
}},
promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}},
}});
(async () => {{
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }}
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
assert json.loads(result.stdout) == {
"name": "TimeoutError",
"phase": "fresh-authorization",
"calls": 2,
}
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
@@ -53,6 +234,375 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
result = run_session_scenario(
"""
boundary.startActivityHeartbeat();
await state.activityListeners.pointerdown();
await state.activityListeners.keydown();
state.now += 60000;
await state.activityListeners.touchstart();
process.stdout.write(JSON.stringify(state));
"""
)
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/session/activity",
"/dashboard/api/v1/session/activity",
]
assert all(request["method"] == "POST" for request in result["requests"])
assert all(
request["headers"]["x-csrf-token"] == "csrf-proof"
for request in result["requests"]
)
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800, idle_expires_at:4102441200};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["savedIdleExpiry"] == "4102441200"
assert result["workerMessages"] == [
{
"type": "stackchain-session-lease",
"expiresAt": 4102444800,
"idleExpiresAt": 4102441200,
}
]
assert result["leaseDelay"] > 0
def test_expired_idle_lease_locks_offline_without_purging_private_work():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '200');
storage.setItem('stackchain.session-idle-expires-at', '99');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == [
"stackchain.private",
"gitea.preference",
"stackchain.session-expires-at",
"stackchain.session-idle-expires-at",
]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_successful_activity_heartbeat_republishes_only_confirmed_idle_deadline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '4102444800');
state.responsePayload = {active:true, idle_expires_at:4102441300};
const recorded = await boundary.recordActivity();
state.recorded = recorded;
state.savedIdleExpiry = storage.getItem('stackchain.session-idle-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["recorded"] is True
assert result["savedIdleExpiry"] == "4102441300"
assert result["workerMessages"] == [
{
"type": "stackchain-session-lease",
"expiresAt": 4102444800,
"idleExpiresAt": 4102441300,
}
]
def test_absolute_expiry_still_purges_after_an_idle_lock():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, expires_at:200, idle_expires_at:150};
await boundary.refreshOfflineLease();
state.now = 150000;
await state.timers[1].callback();
state.deletedAtIdle = [...state.deletedDatabases];
state.now = 200000;
await state.timers[0].callback();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["deletedAtIdle"] == []
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
"/dashboard/login?reason=session-idle",
"/dashboard/login?reason=session-expired",
]
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '1');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_enroll_passkey_bootstraps_with_fresh_authorization_and_web_authentication():
script = f"""
const createSessionBoundary=require({json.dumps(str(SESSION_JS))});
const requests=[];
const responses=[
{{status:428,payload:{{detail:{{code:'step_up_required',action:'enroll_passkey',target:'current_device'}}}}}},
{{status:201,payload:{{grant:'bootstrap-grant',expires_in:90}}}},
{{status:201,payload:{{challenge:'AQID',rp:{{id:'forge.example',name:'Stackchain Dashboard'}},user:{{id:'BAUG',name:'stackchain-operator',displayName:'Stackchain operator'}},pubKeyCredParams:[],excludeCredentials:[],authenticatorSelection:{{userVerification:'required'}}}}}},
{{status:201,payload:{{enrolled:true}}}},
];
const credential={{id:'new-passkey',type:'public-key',rawId:Uint8Array.from([1]).buffer,response:{{attestationObject:Uint8Array.from([2]).buffer,clientDataJSON:Uint8Array.from([3]).buffer,getTransports:()=>['internal']}}}};
const boundary=createSessionBoundary({{
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
credentials:{{create:async options=>{{globalThis.creation=options.publicKey;return credential;}}}},
promptAuthorization:async()=> 'recovery-token',
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
location:{{replace:()=>{{}}}},
}});
(async()=>{{const enrolled=await boundary.enrollPasskey();process.stdout.write(JSON.stringify({{enrolled,requests,creation:globalThis.creation}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
)
output = json.loads(result.stdout)
assert output["enrolled"] is True
assert output["creation"]["challenge"] == {"0": 1, "1": 2, "2": 3}
assert output["creation"]["user"]["id"] == {"0": 4, "1": 5, "2": 6}
assert output["requests"][2]["grant"] == "bootstrap-grant"
verification = json.loads(output["requests"][3]["body"])
assert verification["challenge"] == "AQID"
assert verification["credential"]["response"]["transports"] == ["internal"]
def test_high_impact_fetch_uses_passkey_before_access_token_fallback():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const requests=[];
const responses=[
{{status:428,payload:{{detail:{{code:'step_up_required',action:'close_issue',target:'stackchain/api#7'}}}}}},
{{status:200,payload:{{challenge:'AQID',rpId:'forge.example',userVerification:'required',allowCredentials:[{{type:'public-key',id:'BAUG'}}]}}}},
{{status:201,payload:{{grant:'passkey-grant',expires_in:90}}}},
{{status:200,payload:{{closed:true}}}},
];
let prompted=false;
const credential={{id:'credential',type:'public-key',rawId:Uint8Array.from([4,5,6]).buffer,response:{{authenticatorData:Uint8Array.from([7]).buffer,clientDataJSON:Uint8Array.from([8]).buffer,signature:Uint8Array.from([9]).buffer,userHandle:null}}}};
const boundary=createSessionBoundary({{
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
credentials:{{get:async()=>credential}},
promptAuthorization:async()=>{{prompted=true;return 'must-not-be-requested';}},
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
location:{{replace:()=>{{}}}},
}});
(async()=>{{const response=await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/issues/7/close',{{method:'PATCH'}});process.stdout.write(JSON.stringify({{status:response.status,requests,prompted}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
)
output = json.loads(result.stdout)
assert output["status"] == 200
assert output["prompted"] is False
assert output["requests"][1]["url"].endswith("/passkeys/authorization/options")
assert json.loads(output["requests"][1]["body"]) == {
"action": "close_issue",
"target": "stackchain/api#7",
}
assert output["requests"][2]["url"].endswith("/passkeys/authorization/verify")
assert output["requests"][3]["grant"] == "passkey-grant"
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario(
"""
state.responses = [
{status:428, payload:{detail:{code:'step_up_required', action:'merge_pull', target:'stackchain/api#7'}}},
{status:201, payload:{grant:'one-time-grant', expires_in:90}},
{status:200, payload:{merged:true}},
];
const original = { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({expected_head_sha:'abc123'}) };
const response = await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/pulls/7/merge', original);
state.finalStatus = response.status;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["finalStatus"] == 200
assert result["prompts"] == [
{"action": "merge_pull", "target": "stackchain/api#7"}
]
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
"/dashboard/api/v1/fresh-authorization",
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
]
assert json.loads(result["requests"][1]["body"]) == {
"access_token": "correct horse battery staple",
"action": "merge_pull",
"target": "stackchain/api#7",
}
assert result["requests"][2]["headers"]["x-step-up-grant"] == "one-time-grant"
assert result["requests"][2]["body"] == result["requests"][0]["body"]
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
result = run_session_scenario(
"""
state.responseStatus = 401;
await Promise.all([
boundary.fetch('/dashboard/api/v1/live'),
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True
assert result["replacedAfterDeletion"] is True
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
assert result["replacedAfterDeletion"] is True
def test_idle_response_locks_without_clearing_private_queued_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["workerMessages"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_revocation_message_clears_window_storage_before_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_idle_message_locks_without_clearing_window_storage():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
state.responseStatus = 401;
await boundary.fetch('https://untrusted.example/api/private');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == []
def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario(
"""
@@ -68,9 +618,172 @@ process.stdout.write(JSON.stringify(state));
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
assert result["assignedAfterDeletion"] is True
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
result = run_session_scenario(
"""
state.failDeletion = true;
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
process.stdout.write(JSON.stringify(state));
"""
)
assert result["assigned"] == ""
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
assert result["signOutError"] == "Could not clear private queued work from this device."
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
result = run_session_scenario(
"""
await boundary.signOutAllDevices();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["confirmations"] == [
"Sign out every device? You will need to sign in again everywhere."
]
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/sessions"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert result["remaining"] == ["gitea.preference"]
assert result["assigned"] == "/dashboard/login"
def test_active_devices_can_be_loaded_and_one_remote_device_revoked():
result = run_session_scenario(
"""
state.responsePayload = {devices:[
{management_id:'remote-id-123456789', device_label:'Pixel <script>', created_at:1000, expires_at:2000, current:false},
{management_id:'current-id-12345678', device_label:'Work laptop', created_at:1100, expires_at:2100, current:true},
]};
const devices = await boundary.listActiveDevices();
await boundary.revokeActiveDevice(devices[0]);
state.devices = devices;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["devices"][0]["device_label"] == "Pixel <script>"
assert result["requests"][0]["url"] == "/dashboard/api/v1/sessions"
assert result["requests"][1]["url"] == (
"/dashboard/api/v1/sessions/remote-id-123456789"
)
assert result["requests"][1]["method"] == "DELETE"
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
assert result["confirmations"] == ["Sign out Pixel <script>?"]
def test_enrolled_passkeys_can_be_loaded_and_selectively_removed():
result = run_session_scenario(
"""
state.responses = [
{status:200,payload:{passkeys:[
{management_id:'passkey-id-12345678', device_label:'Old phone <script>', created_at:1000, active:false, current:false},
]}},
{status:200,payload:{revoked:true,current_session:false,session_revoked:false}},
];
const passkeys = await boundary.listPasskeys();
const outcome = await boundary.revokePasskey(passkeys[0]);
state.passkeys = passkeys;
state.outcome = outcome;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["passkeys"][0]["device_label"] == "Old phone <script>"
assert result["outcome"] == {
"revoked": True,
"current_session": False,
"session_revoked": False,
}
assert result["requests"][0]["url"] == "/dashboard/api/v1/passkeys"
assert result["requests"][1]["url"] == (
"/dashboard/api/v1/passkeys/passkey-id-12345678"
)
assert result["requests"][1]["method"] == "DELETE"
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
assert result["confirmations"] == [
"Remove the passkey for Old phone <script>? This cannot be undone."
]
def test_security_activity_pages_are_loaded_with_stable_cursors():
result = run_session_scenario(
"""
state.responsePayload = {events:[{id:7,kind:'sign_in',method:'passkey',device_label:'Phone',target:'dashboard',created_at:1000}],next_cursor:7};
const first = await boundary.listSecurityEvents();
const second = await boundary.listSecurityEvents(first.next_cursor);
state.first = first;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["first"]["events"][0]["method"] == "passkey"
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/security-events?limit=25",
"/dashboard/api/v1/security-events?limit=25&cursor=7",
]
def test_security_activity_formats_failed_sign_in_alerts_for_mobile_scanability():
result = run_session_scenario(
"""
state.formatted = boundary.formatAuthenticationAlert({
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["formatted"] == {
"title": "12 failed token sign-ins · 3 blocked",
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
}
def test_security_activity_renders_authentication_alerts_before_journal_events():
source = SESSION_JS.read_text()
alerts = source.index("page.authentication_alerts.forEach")
events = source.index("page.events.forEach")
assert alerts < events
assert "boundary.formatAuthenticationAlert(alert)" in source
assert "row.className = 'security-event authentication-alert'" in source
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".authentication-alert { border-color:#d69e2e;" in css
def test_security_activity_explains_pending_outcome_confirmation():
source = SESSION_JS.read_text()
assert "Outcome confirmation pending" in source
assert "event.status === 'pending'" in source
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
source = SESSION_JS.read_text()
assert "passkey_enrolled: 'Passkey enrolled'" in source
assert "title.textContent = labels[event.kind]" in source
assert "details.textContent =" in source
def test_security_activity_labels_consequential_pull_review_decisions():
source = SESSION_JS.read_text()
assert "pull_review_approved: 'Pull request approved'" in source
assert "pull_review_changes_requested: 'Changes requested'" in source
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
@@ -91,3 +804,14 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert '<script src="static/session.js"></script>' in html
assert html.index('static/session.js') < html.index('static/markdown.js')
assert '<button id="sign-out" type="button">Sign out &amp; clear this device</button>' in html
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
assert '<button id="active-devices" type="button">Active devices</button>' in html
assert 'id="active-devices-sheet"' in html
assert 'aria-label="Active devices and passkeys"' in html
assert 'aria-labelledby="enrolled-passkeys-title"' in html
assert 'id="enrolled-passkeys-list"' in html
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
assert 'aria-labelledby="security-activity-title"' in html
assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html
assert '#sign-out-all { min-height:44px; }' in html
+173 -2
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
@@ -121,6 +121,112 @@ process.stdout.write(JSON.stringify(drafts));
assert output[1]["label"] == "Queued issue"
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
decision:'approve',expectedHeadSha:'abc',comments:[{{path:'app.js',body:'Nice',new_position:4}}],ownerLogin:'timmy',status:'queued',queuedAt:200
}}]}})]]);
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["label"] == "Queued review"
assert output["outbox_kind"] == "pull-review"
assert output["title"] == "stackchain/web#8"
assert output["route"] == {"kind": "review", "repository": "stackchain/web", "number": 8}
assert output["copy_text"] == "Looks good\n\nInline feedback\napp.js · new line 4: Nice"
assert output["quarantined"] is False
def test_draft_inbox_exposes_held_review_as_awaiting_foreground_authorization():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
decision:'approve',expectedHeadSha:'abc',comments:[],ownerLogin:'timmy',
status:'authorization',error:'Fresh authorization required',queuedAt:200
}}]}})]]);
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
process.stdout.write(JSON.stringify({{item:inbox.list()[0],partition:inbox.partition()}}));
"""
output = run_node(script)
assert output["item"]["status"] == "authorization"
assert output["item"]["label"] == "Review awaiting authorization"
assert output["item"]["authorization_required"] is True
assert output["partition"]["counts"]["authorization"] == 1
assert output["partition"]["retryable"] == []
def test_draft_inbox_exposes_queued_issue_closure_as_awaiting_authorization():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
ownerLogin:'timmy',status:'queued',queuedAt:200
}}]}})]]);
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
const item=inbox.list()[0];
process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
"""
output = run_node(script)
item = output["item"]
assert item["label"] == "Awaiting authorization"
assert item["status"] == "authorization"
assert item["authorization_required"] is True
assert item["outbox_kind"] == "issue-close"
assert item["title"] == "stackchain/dashboard#27"
assert item["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
assert item["quarantined"] is False
assert output["partition"]["counts"] == {
"waiting": 0, "sending": 0, "attention": 0, "authorization": 1
}
assert output["partition"]["retryable"] == []
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
id:'continue-1',operationId:'continue-1',repository:'stackchain/dashboard',
title:'Resume this issue',body:'',ownerLogin:'timmy',status:'completion',queuedAt:200,
completionIntent:'create-and-start',deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume this issue'}}
}}]}})]]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null}};
const item = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["status"] == "completion"
assert output["label"] == "Created · ready to start"
assert output["continuation"] is True
assert output["quarantined"] is False
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:7,body:'Possibly posted',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',error:'Verify it was not posted before retrying.',queuedAt:200}}
]}})]]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
process.stdout.write(JSON.stringify(drafts[0]));
"""
output = run_node(script)
assert output["delivery_state"] == "uncertain"
assert output["label"] == "Verify delivery"
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
@@ -147,6 +253,36 @@ process.stdout.write(JSON.stringify(drafts));
assert by_id["mine"]["quarantined"] is False
def test_draft_inbox_partitions_delivery_center_and_counts_only_safe_waiting_retries():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values = new Map([
['stackchain.issue-comment.v1:stackchain/api#17', 'Unfinished comment'],
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
{{id:'waiting',repository:'o/r',title:'Waiting issue',body:'Body',ownerLogin:'timmy',status:'queued',queuedAt:100,lastAttemptAt:90,lastAttemptError:'Network unavailable'}},
{{id:'sending',repository:'o/r',title:'Sending issue',body:'Body',ownerLogin:'timmy',status:'sending',queuedAt:200}},
{{id:'attention',repository:'o/r',title:'Broken issue',body:'Body',ownerLogin:'timmy',status:'attention',queuedAt:300}},
{{id:'other-user',repository:'o/r',title:'Private issue',body:'Body',ownerLogin:'alexander',status:'queued',queuedAt:400}}
]}})],
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:4,body:'Maybe sent',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',queuedAt:500}}
]}})],
]);
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
process.stdout.write(JSON.stringify(inbox.partition()));
"""
output = run_node(script)
assert len(output["drafts"]) == 1
assert len(output["deliveries"]) == 5
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
assert waiting["last_attempt_at"] == 90
assert waiting["last_attempt_error"] == "Network unavailable"
@pytest.mark.anyio
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
html = await dashboard()
@@ -159,7 +295,42 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
assert "item.kind === 'authored-outbox' && closureOutbox" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html
@pytest.mark.anyio
async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_waiting_work():
html = await dashboard()
assert 'class="delivery-center"' in html
assert 'id="retry-waiting-deliveries"' in html
assert 'Waiting <strong data-delivery-count="waiting">' in html
assert 'Sending <strong data-delivery-count="sending">' in html
assert 'Needs attention <strong data-delivery-count="attention">' in html
assert 'Authorize <strong data-delivery-count="authorization">' in html
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
assert "deliveryCenter.retryable.length" in html
assert "item.status === 'sending' ? 'Sending'" in html
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
assert '.delivery-center button { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture():
html = await dashboard()
assert 'class="draft-authorize"' in html
assert '>Authorize &amp; close</button>' in html
assert "list.querySelectorAll('.draft-authorize')" in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
@pytest.mark.anyio
+4 -3
View File
@@ -6,13 +6,14 @@ import pytest
from src import main
from src import gitea_proxy
from tests.dashboard_bundle import dashboard_bundle_text
DASHBOARD = Path("frontend/index.html")
def test_dashboard_has_realtime_gitea_event_stream_widget():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert "Gitea event stream" in html
assert "id=\"gitea-events\"" in html
@@ -25,14 +26,14 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
def test_event_stream_reports_when_activity_was_refreshed():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert 'id="gitea-events-status"' in html
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert "setEventStreamStatus('Update failed · showing last activity')" in html
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
+91
View File
@@ -0,0 +1,91 @@
import json
import subprocess
from pathlib import Path
LOADER = Path(__file__).parents[1] / "frontend" / "feature-loader.js"
def run_loader(scenario: str) -> dict:
harness = f"""
const fs=require('fs'); const vm=require('vm');
const state={{appends:0, removed:0}};
const document={{
head:{{appendChild(node){{ state.appends++; state.node=node; }} }},
createElement(){{ return {{remove(){{state.removed++;}}}}; }},
}};
const context={{document, setTimeout, clearTimeout, console}};
context.globalThis=context;
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(LOADER))},'utf8'),context);
const createFeatureLoader=context.createFeatureLoader;
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_concurrent_feature_requests_share_one_script():
result = run_loader("""
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
const first=loader.load('issue-capture'); const second=loader.load('issue-capture');
state.node.onload(); await Promise.all([first,second]);
console.log(JSON.stringify({appends:state.appends,same:first===second,ready:loader.ready('issue-capture')}));
""")
assert result == {"appends": 1, "same": True, "ready": True}
def test_failed_feature_request_can_retry_without_reload():
result = run_loader("""
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
const failed=loader.load('issue-capture').catch(error=>error.message); state.node.onerror();
const message=await failed; const retry=loader.load('issue-capture'); state.node.onload(); await retry;
console.log(JSON.stringify({appends:state.appends,removed:state.removed,message,ready:loader.ready('issue-capture')}));
""")
assert result == {"appends": 2, "removed": 1, "message": "Could not load issue capture.", "ready": True}
def test_feature_gate_exposes_loading_failure_and_success_to_mobile_controls():
result = run_loader("""
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
const loader=createFeatureLoader({document, urls:{'issue-capture':'feature-a.js'}, timeoutMs:100});
const failed=loader.run('issue-capture',{trigger,status},()=>{opened++;});
const loading={disabled:trigger.disabled,status:status.textContent}; state.node.onerror();
const first=await failed;
const failedState={disabled:trigger.disabled,status:status.textContent,opened};
const retry=loader.run('issue-capture',{trigger,status},()=>{opened++;}); state.node.onload();
const second=await retry;
console.log(JSON.stringify({loading,failedState,first,second,opened,finalStatus:status.textContent}));
""")
assert result == {
"loading": {"disabled": True, "status": "Loading issue capture…"},
"failedState": {
"disabled": False,
"status": "Issue capture could not load. Tap New issue to retry.",
"opened": 0,
},
"first": False,
"second": True,
"opened": 1,
"finalStatus": "",
}
def test_pull_workspace_failure_is_accessible_and_retryable_from_same_card():
result = run_loader("""
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
const loader=createFeatureLoader({document, urls:{'pull-workflow':'feature-pull.js'}, timeoutMs:100});
const failed=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
state.node.onerror(); const first=await failed;
const failedStatus=status.textContent;
const retry=loader.run('pull-workflow',{trigger,status,retryLabel:'Tap the work card to retry.'},()=>{opened++;});
state.node.onload(); const second=await retry;
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
""")
assert result == {
"first": False,
"second": True,
"failedStatus": "Pull workspace could not load. Tap the work card to retry.",
"opened": 1,
"appends": 2,
}
+272
View File
@@ -0,0 +1,272 @@
import gzip
import re
import shutil
from pathlib import Path
import httpx
import pytest
from starlette.requests import Request
from src import main
from src.frontend_bundle import build_frontend
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>')
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
first = build_frontend(FRONTEND)
second = build_frontend(FRONTEND)
assert first.runtime_name == second.runtime_name
assert first.runtime_bytes == second.runtime_bytes
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
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 = build_frontend(changed_frontend)
assert changed.runtime_name != first.runtime_name
assert changed.runtime_bytes != first.runtime_bytes
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"}
capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes
assert b"function createIssueCapture" in capture.runtime_bytes
assert b"function createPullSheet" not in first.runtime_bytes
assert b"function createReviewController" not in first.runtime_bytes
assert b"function createPullSheet" in pull_workflow.runtime_bytes
assert b"function createReviewController" in pull_workflow.runtime_bytes
assert len(first.runtime_gzip_bytes) <= 95 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
capture_source = changed_frontend / "create-issue-sheet.js"
capture_source.write_text(capture_source.read_text() + "\n// feature-only change\n")
changed = build_frontend(changed_frontend)
assert changed.runtime_name == first.runtime_name
assert changed.feature_bundles["issue-capture"].runtime_name != capture.runtime_name
pull_source = changed_frontend / "pull-sheet.js"
pull_source.write_text(pull_source.read_text() + "\n// pull-workflow-only change\n")
pull_changed = build_frontend(changed_frontend)
assert pull_changed.runtime_name == first.runtime_name
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
@pytest.mark.anyio
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
build = build_frontend(FRONTEND)
capture = build.feature_bundles["issue-capture"]
request = Request(
{"type": "http", "method": "GET", "path": f"/{capture.runtime_name}",
"headers": [(b"accept-encoding", b"identity")]}
)
response = await feature_bundle("issue-capture", capture.runtime_digest, request)
assert response.body == capture.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
with pytest.raises(Exception) as rejected:
await feature_bundle("issue-capture", "missing", request)
assert rejected.value.status_code == 404
def test_css_only_release_gets_a_new_shell_generation(tmp_path):
first = build_frontend(FRONTEND)
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
(changed_frontend / "dashboard.css").write_text(
(changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n"
)
changed = build_frontend(changed_frontend)
assert changed.runtime_name == first.runtime_name
assert changed.runtime_bytes == first.runtime_bytes
assert changed.shell_digest != first.shell_digest
assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in (
changed.service_worker_source
)
@pytest.mark.parametrize(
"asset",
[
"manifest.webmanifest",
"icons/stackchain-192.png",
"icons/stackchain-512.png",
"background-issue-sync.js",
"service-worker.js",
],
)
def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset):
first = build_frontend(FRONTEND)
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
path = changed_frontend / asset
path.write_bytes(path.read_bytes() + b"\nrelease-change")
changed = build_frontend(changed_frontend)
assert changed.shell_digest != first.shell_digest
def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
first = build_frontend(FRONTEND)
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v87';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
changed = build_frontend(changed_frontend)
assert changed.shell_digest == first.shell_digest
assert changed.service_worker_source == first.service_worker_source
@pytest.mark.anyio
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
async def test_runtime_route_returns_the_precomputed_gzip_representation():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"gzip")]}
)
response = await runtime_bundle(build.runtime_digest, request)
assert response.body == build.runtime_gzip_bytes
assert response.headers["content-encoding"] == "gzip"
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
assert response.headers["vary"] == "Accept-Encoding"
@pytest.mark.anyio
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
class UnexpectedDynamicCompression:
def __init__(self, *args, **kwargs):
raise AssertionError("immutable runtime entered dynamic compression")
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": accept_encoding},
)
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
@pytest.mark.anyio
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
stylesheet = await client.get(
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
)
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
assert page.headers["content-encoding"] == "gzip"
assert page.headers["cache-control"] == "no-cache"
assert page.headers["content-security-policy"]
assert main.FRONTEND_BUILD.runtime_name in page.text
assert stylesheet.headers["content-encoding"] == "gzip"
assert "mobile-task-dock" in stylesheet.text
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
assert "content-encoding" not in health.headers
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
build = build_frontend(FRONTEND)
assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
for source in build.page_sources:
if source == "static/background-issue-sync.js":
continue
assert f"BASE + '{source}'" not in build.service_worker_source
@pytest.mark.anyio
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"identity")]}
)
html = await dashboard()
bundle = await runtime_bundle(build.runtime_digest, request)
worker = await service_worker()
assert build.runtime_name in html
assert bundle.body == build.runtime_bytes
assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable"
assert worker.headers["cache-control"] == "no-cache"
assert build.runtime_name.encode() in worker.body
+6 -1
View File
@@ -133,7 +133,10 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
},
})
if request.url.path.endswith("/issues/7"):
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
return httpx.Response(200, json={
"number": 7, "state": "open", "assignees": [],
"body": "Deploy fails after **three** retries.",
})
if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={
"id": 9,
@@ -178,6 +181,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"created_at": "2026-08-06T12:30:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"conversation": {
"comments": [{
"id": 9,
+76
View File
@@ -306,3 +306,79 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
main._available_issue_snapshot_task = None
assert calls == ["start", "available scan cancelled", "stop (done)"]
@pytest.mark.anyio
async def test_application_shutdown_drains_authored_mutation_before_transport(monkeypatch):
calls = []
started = asyncio.Event()
release = asyncio.Event()
async def authored_mutation():
started.set()
await release.wait()
calls.append("mutation persisted")
return {"id": 461}
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
calls.append("stop")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
context = main.app.router.lifespan_context(main.app)
await context.__aenter__()
task = asyncio.create_task(authored_mutation())
main._authored_action_operations["shutdown-drain-461"] = (
("comment", "stackchain/dashboard", 461),
task,
0.0,
)
await started.wait()
shutdown = asyncio.create_task(context.__aexit__(None, None, None))
await asyncio.sleep(0)
assert not shutdown.done()
assert calls == ["start"]
release.set()
await shutdown
assert calls == ["start", "mutation persisted", "stop"]
@pytest.mark.anyio
async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation(monkeypatch):
calls = []
started = asyncio.Event()
async def stalled_mutation():
started.set()
try:
await asyncio.Event().wait()
finally:
calls.append("mutation cancelled")
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
calls.append("stop")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
context = main.app.router.lifespan_context(main.app)
await context.__aenter__()
task = asyncio.create_task(stalled_mutation())
main._authored_action_operations["stalled-shutdown-461"] = (
("review", "stackchain/dashboard", 461),
task,
0.0,
)
await started.wait()
await asyncio.wait_for(context.__aexit__(None, None, None), timeout=0.2)
assert task.cancelled()
assert main._authored_action_operations == {}
assert calls == ["start", "mutation cancelled", "stop"]
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more