Compare commits

..
Author SHA1 Message Date
timmy 2cbeba30fd feat: comment and continue Today work (#429)
CI / lint (pull_request) Successful in 50s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
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 / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 4s
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 / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 4s
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 / lint (pull_request) Successful in 38s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 37s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 4s
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 / lint (pull_request) Successful in 35s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 33s
CI / build-release (pull_request) Successful in 4s
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 / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 4s
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
CI / lint (push) Successful in 31s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-08 17:20:47 +00:00
timmy 0ea264a942 feat: claim work from unread updates (#321)
CI / lint (pull_request) Successful in 32s
CI / build-frontend (pull_request) Successful in 4s
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
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
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 / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
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
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
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
timmy 0ac1277479 perf: keep session validation off event loop (#275)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 07:15:37 +00:00
rockachopa 6185f6a7ff Merge pull request 'Surface deadline-critical work in mobile Attention' (#274) from timmy/273-mobile-deadline-attention into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 26s
2026-08-08 06:55:34 +00:00
timmy 98d6a29fa0 feat: surface deadline-critical work in Attention (#273)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 27s
2026-08-08 06:54:18 +00:00
timmy 3decdb4cac Merge pull request 'Keep sign-in retry guidance within the mobile viewport' (#272)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 29s
Closes #270
2026-08-08 06:27:17 +00:00
timmy 0fa3e675dc fix: keep sign-in retry state within mobile viewport (#270)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 6s
2026-08-08 06:26:13 +00:00
timmy 3210b8be6d Merge pull request 'Throttle repeated operator sign-in failures' (#271)
CI / lint (push) Successful in 26s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
Closes #270
2026-08-08 06:20:25 +00:00
timmy 87becb08ad feat: throttle repeated operator sign-in failures (#270)
CI / build-frontend (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 26s
2026-08-08 06:19:08 +00:00
timmy 334fe77e84 Revoke captured operator sessions on logout (#269)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 25s
Closes #268
2026-08-08 05:57:06 +00:00
timmy c8de28ca0c feat: revoke operator sessions on logout (#268)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 24s
2026-08-08 05:55:51 +00:00
timmy 741d768fc3 Merge pull request 'Resume queued delivery after operator sign-in' (#267) from timmy/266-resume-queued-delivery into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
CI / lint (push) Successful in 24s
2026-08-08 05:34:02 +00:00
timmy acd2d82d10 fix: resume queued delivery after sign-in (#266)
CI / build-frontend (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 24s
2026-08-08 05:32:29 +00:00
timmy 65d1f9f194 Confirm offline work only after durable background admission (#265)
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 25s
Closes #264
2026-08-08 05:11:20 +00:00
timmy c74c63d29c feat: make background queue admission durable (#264)
CI / build-frontend (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 25s
2026-08-08 05:10:11 +00:00
timmy 94aad3e76e Merge pull request 'Fail closed when dashboard authentication is not explicitly configured' (#263) from timmy/262-fail-closed-dashboard-auth into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 25s
Closes #262
2026-08-08 04:33:25 +00:00
timmy 00a956b1fb feat: fail closed on invalid dashboard auth (#262)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 04:32:15 +00:00
rockachopa 7bdfe62495 Merge pull request 'Notify operators when background queued work finishes' (#261) from timmy/260-background-delivery-receipts into main
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 30s
2026-08-08 04:19:47 +00:00
timmy 1b10a5a8a8 feat: notify on background delivery completion (#260)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 04:18:32 +00:00
timmy a7afd85634 Merge pull request 'Require operator sessions for privileged dashboard access' (#259) from timmy/258-operator-session into main
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
CI / lint (push) Successful in 24s
2026-08-08 03:42:53 +00:00
127 changed files with 21954 additions and 3606 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
+231 -26
View File
@@ -21,14 +21,63 @@ inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
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
creation and assignment permission. 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. 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. Delivery or local-admission failure preserves both the 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.
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. 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
@@ -45,34 +94,105 @@ comment path and submits the summary, decision, and inline comments in one revie
request. 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. For any deployment not already behind an
authenticated gateway, configure the built-in single-operator boundary with two
independent high-entropy secrets. The access token is entered at `/login`; the
browser receives only a short-lived signed session and CSRF proof. Generate the
values in your secret manager (for example, `openssl rand -hex 32`) and inject
access is intentionally disabled. Authentication defaults to fail-closed
`operator` mode. It requires two independent secrets of at least 24 characters;
missing, short, reused, or invalid configuration leaves `/healthz` available but
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
the browser receives only a short-lived signed session and CSRF proof. Generate
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
them at runtime—never commit them:
```bash
export GITEA_URL='https://forge.example.com'
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
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 eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# 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
# 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.
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.
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. 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. Existing two-column registries are
migrated in place and their live sessions remain valid.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require 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.
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
the injected value and restarting the service; changing the signing secret expires
all sessions immediately.
all sessions immediately. The signing key is never derived from the access token.
Open `http://127.0.0.1:8000/` only for an unprotected local development run. To
verify the backend and its Gitea connection directly, sign in and request
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
For direct, unproxied local development only, set
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
clients; do not use it behind a reverse proxy, whose loopback connection would hide
the browser's peer address. To verify the backend and its Gitea connection in
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
successful response is JSON containing
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
to search commands plus issues and pull requests across every repository visible to
the configured Gitea token. Remote search starts after two characters, is debounced,
@@ -100,6 +220,14 @@ 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. Each bounded opaque token includes a per-process generation, so a token
from another worker or from before a restart 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.
## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
@@ -108,13 +236,20 @@ 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.
early. 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
@@ -127,21 +262,51 @@ 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.
This account-bound cache is 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** deletes both stores, and opting out or seven-day expiry deletes them
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. Browsers without
idempotency key. Installed browsers can explicitly enable **Notify me when queued
work finishes**; permission is requested only from that user gesture and the choice
is stored for the confirmed account in the private background outbox database.
Successful deliveries produce privacy-safe receipts that open the created issue or
source conversation, while permanent validation failures open Drafts for recovery.
Authentication expiry during a worker mutation is recoverable: the worker releases the
claim without creating an Attention receipt, stops that drain, and keeps the original
idempotency key. Loading the authenticated dashboard after signing in asks the worker
to resume queued delivery automatically.
Notification text never includes issue titles, comment bodies, or validation details.
The preference is off by default, unsupported or denied browsers retain foreground
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
Browsers without
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
queued and needs-attention messages with explicit send/discard controls; reopening the
dashboard reconciles worker completions and permanent failures into the visible outbox.
@@ -188,6 +353,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'
```
@@ -196,6 +365,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).
+9
View File
@@ -0,0 +1,9 @@
import pytest
@pytest.fixture(autouse=True)
def explicit_local_dashboard_auth_mode(monkeypatch):
"""Keep tests intentional now that deployed authentication fails closed."""
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
+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;
+106 -17
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', 'pull-review', 'issue-close']);
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 {
@@ -14,17 +33,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
} catch (_error) { return []; }
}
function write(items) {
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
if (backgroundSync?.reconcile) {
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(message) {
function enqueue(message, mirror = true) {
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
@@ -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,12 +74,35 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(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 || ''),
} : {}),
};
items.push(item);
write(items);
write(items, mirror);
return item;
}
async function enqueueDurably(message) {
const item = enqueue(message, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read(), 'authored');
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function update(id, changes) {
let updated = null;
write(read().map(item => {
@@ -63,6 +115,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
status: 'queued',
};
delete updated.error;
delete updated.deliveryState;
return updated;
}));
return updated;
@@ -80,6 +133,12 @@ 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 === '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';
}
@@ -99,17 +158,32 @@ 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 === '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 {
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) {
@@ -120,6 +194,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
}
return { error, transient: !permanent };
@@ -156,7 +231,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 };
}
@@ -170,11 +255,15 @@ 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 } : {}),
}];
return [item];
});
@@ -182,7 +271,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+265 -21
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');
@@ -38,6 +45,13 @@ 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 } = {}) {
@@ -48,6 +62,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
const existing = await records.getAll();
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
for (const current of existing) {
if (current.recordType === 'receipt-preference') continue;
const currentLane = current.outboxLane || 'issue';
if (currentLane !== outboxLane) continue;
const replacement = incoming.get(current.id);
@@ -81,6 +96,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
async function claimBatch(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.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());
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
}
const claimed = selected.map(item => ({
...item, status: 'sending', claimUntil: timestamp + claimMs,
}));
for (const item of claimed) await records.put(item);
return claimed;
});
}
async function claim(id, ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
@@ -112,26 +153,154 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
function preferenceId(ownerLogin) {
return 'receipt-preference:' + String(ownerLogin || '').trim();
}
async function setReceiptPreference(ownerLogin, enabled) {
const login = String(ownerLogin || '').trim();
if (!login) return;
return transact(async records => {
const id = preferenceId(login);
if (!enabled) return records.delete(id);
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
});
}
async function getReceiptPreference(ownerLogin) {
const id = preferenceId(ownerLogin);
return transact(async records => Boolean(
(await records.getAll()).find(item => item.id === id)?.enabled
));
}
return {
reconcile,
upsert,
claim,
claimNext,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
claimBatch,
complete: (id, deliveredIssue) => update(id, item => ({
...item, status: 'sent', claimUntil: 0,
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
})),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
snapshot: () => transact(records => records.getAll()),
fail: (id, error, deliveryState) => update(id, item => ({
...item, status: 'attention', claimUntil: 0, error,
...(deliveryState ? { deliveryState } : {}),
})),
snapshot: () => transact(async records =>
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
countBlocked: ownerLogin => transact(async records =>
(await records.getAll()).filter(item => item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
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);
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 === 'notification-read') {
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
}
if (item.kind === 'update-reply') {
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) };
}
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
}
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);
}
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 === '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(
@@ -177,14 +346,28 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await fetchJson(request.url, request.options);
await store.complete(item.id);
return item.kind ? { message: delivered } : { issue: delivered };
const delivered = await requestJson(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;
}
await store.complete(item.id, 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);
throw error;
}
if (status >= 400 && status < 500) {
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
return { attention: true, error };
await store.fail(
item.id,
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);
throw error;
@@ -192,6 +375,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
}
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);
@@ -199,30 +383,90 @@ 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 };
while (true) {
const item = await store.claimNext(login);
if (!item) break;
const result = await deliver(item);
if (!login) return { confirmed, blocked: 0, attention, 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.receipt) receipts.push(result.receipt);
};
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 };
return { confirmed, blocked, attention, 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,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
};
}
+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 };
});
+48
View File
@@ -0,0 +1,48 @@
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
let inFlight = null;
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body);
} catch (error) {
if (!canQueue(error)) throw error;
const admission = await queue({
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository,
number: item.number,
body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
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)),
};
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
+80 -11
View File
@@ -1,3 +1,15 @@
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 createContextPoller({
fetchContext,
onSnapshot,
@@ -5,11 +17,16 @@ function createContextPoller({
isHidden = () => false,
setTimer = setTimeout,
clearTimer = clearTimeout,
setDeadlineTimer = setTimeout,
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
}) {
let inFlight = null;
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
function cancelTimer() {
if (timer !== null) clearTimer(timer);
@@ -25,36 +42,81 @@ function createContextPoller({
}, intervalMs);
}
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;
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) onError(error);
return null;
})
.finally(() => {
inFlight = null;
requestState.settled = true;
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
});
return inFlight;
return requestState.promise;
}
function setVisible(visible) {
cancelTimer();
if (!visible) return Promise.resolve(null);
return refresh();
return refresh({ force: true });
}
return {
@@ -64,10 +126,17 @@ function createContextPoller({
stop() {
stopped = true;
cancelTimer();
if (activeRequest) {
const request = activeRequest;
activeRequest = null;
supersede(request);
}
},
};
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
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;
+66 -1
View File
@@ -23,6 +23,10 @@ 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 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 +144,66 @@ 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;
}
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 +230,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
+403
View File
@@ -0,0 +1,403 @@
: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 { 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; }
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-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%; }
.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%; }
.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; }
.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-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
.review-diff-line { display:block; min-width:max-content; }
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
.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; }
.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-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-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.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-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%; }
.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; 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-repository-more { min-height:44px; width:100%; }
.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-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; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.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; }
.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 .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
+11 -5
View File
@@ -8,17 +8,23 @@ 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 (!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));
},
};
}
+24 -6
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,12 @@ 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' : '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(' — ')),
@@ -135,20 +139,34 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
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 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',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
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') : '',
+186 -3065
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};
});
+128 -20
View File
@@ -8,22 +8,22 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
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) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
coordinator?.notify('issue');
if (backgroundSync?.reconcile) {
if (mirror && backgroundSync?.reconcile) {
Promise.resolve(backgroundSync.reconcile(items))
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
}
}
function enqueue(draft) {
function enqueue(draft, mirror = true) {
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
const items = read();
@@ -39,33 +39,79 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
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);
items.push(item);
write(items);
write(items, mirror);
return item;
}
function update(id, draft) {
async function enqueueDurably(draft) {
const item = enqueue(draft, false);
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function update(id, draft, mirror = true) {
let updated = null;
write(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 changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
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,
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;
delete updated.error;
delete updated.deliveryState;
return updated;
}));
}), mirror);
return updated;
}
async function updateDurably(id, draft) {
const item = update(id, draft, false);
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item, background: false, durability: 'foreground-only' };
}
try {
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item, background: true, durability: 'background' };
} catch (error) {
return { item, background: false, durability: 'foreground-only', error };
}
}
function discard(id) {
const items = read();
if (!items.some(item => item.id === id)) return false;
@@ -104,12 +150,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
if (!issue) return { blocked: true };
discard(item.id);
return { issue };
return { issue, item };
} catch (error) {
const status = Number(error?.status || 0);
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),
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
}
return { error, transient: !(status >= 400 && status < 500) };
@@ -122,16 +169,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) {
@@ -146,9 +203,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) {
@@ -160,9 +237,15 @@ 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 [];
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),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
});
@@ -170,7 +253,32 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return items;
}
return { enqueue, update, 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, update, updateDurably, discard, flush, retry, reconcileBackground,
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
+26
View File
@@ -22,6 +22,7 @@ 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;
@@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
loadHandoffCandidates(item) {
return fetchJson(issuePath(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -209,6 +215,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);
+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;
+119
View File
@@ -0,0 +1,119 @@
(function (root, factory) {
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 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;
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-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;
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
timer = setIntervalImpl(() => {
remaining -= 1;
if (remaining <= 0) {
clearIntervalImpl(timer);
timer = null;
button.disabled = false;
status.textContent = 'You can try signing in again.';
return;
}
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
}, 1000);
}
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, device_label: deviceLabel }),
});
} catch (_error) {
form.reset();
status.textContent = 'Sign-in failed. Check your connection and try again.';
return;
}
form.reset();
if (response.ok) {
location.replace(continuation);
return;
}
if (response.status === 429) {
showRetryCountdown(response.headers.get('Retry-After'));
return;
}
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit, showReason };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({
form,
status,
button,
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 data = new FormData(form);
const accessToken = data.get('access_token');
const deviceLabel = data.get('device_label');
controller.submit(accessToken, deviceLabel);
});
}
+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('');
};
});
+92
View File
@@ -0,0 +1,92 @@
(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;
active.workspace.scrollIntoView({ block:'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.composer.addEventListener('focus', entry.onFocus);
entry.workspace.addEventListener('focusout', entry.onFocusOut);
}
function unbind(entry) {
entry.composer.removeEventListener('focus', 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};
});
+147 -21
View File
@@ -14,6 +14,25 @@ function issueDueState(dueDate, now) {
};
}
function urgencyReason(item) {
if (!item?.is_assigned || item.kind !== 'issue') return '';
const priorityLabels = ['p0', 'priority-high', 'critical'];
const priorityLabel = (item.labels || []).find(label =>
priorityLabels.includes(String(label).toLowerCase())
);
if (priorityLabel) return priorityLabel + ' priority';
return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : '';
}
function attentionReason(item) {
if (item?.has_update) return 'Unread update';
return urgencyReason(item) || (item?.is_review ? 'Needs your review' : '');
}
function needsAttention(item) {
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
}
function buildMyWork(data, now = new Date()) {
const login = data.user?.login || '';
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
@@ -28,7 +47,7 @@ function buildMyWork(data, now = new Date()) {
const assigned = (item.assignees || []).includes(login);
const isReview = (item.work_reasons || []).includes('review_requested');
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
return {
const normalized = {
...item,
key: (item.repository || 'unknown') + '#' + item.number,
is_review: isReview,
@@ -41,6 +60,9 @@ function buildMyWork(data, now = new Date()) {
_priority: priorityLabel ? 0 :
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
};
normalized.needs_attention = needsAttention(normalized);
normalized.attention_reason = attentionReason(normalized);
return normalized;
});
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
@@ -50,6 +72,8 @@ function buildMyWork(data, now = new Date()) {
const existing = byKey.get(subjectKind + ':' + key);
if (existing) {
existing.has_update = true;
existing.needs_attention = true;
existing.attention_reason = 'Unread update';
existing.notification_id = update.id;
existing.url = update.url || existing.url;
existing.updated_at = update.updated_at || existing.updated_at;
@@ -64,6 +88,8 @@ function buildMyWork(data, now = new Date()) {
is_review: false,
is_assigned: false,
has_update: true,
needs_attention: true,
attention_reason: 'Unread update',
notification_id: update.id,
reason: 'Unread update',
_priority: 1,
@@ -84,7 +110,10 @@ function acknowledgeNotification(items, notificationId) {
if (item.notification_id !== notificationId) return [item];
if (item.kind === 'update') return [];
const { notification_id, ...acknowledged } = item;
return [{ ...acknowledged, has_update: false }];
const next = { ...acknowledged, has_update: false };
if ('needs_attention' in item) next.needs_attention = needsAttention(next);
if ('attention_reason' in item) next.attention_reason = attentionReason(next);
return [next];
});
}
@@ -251,6 +280,8 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
function createNotificationReader({
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
queueRead = null,
loadSaved = () => null,
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
@@ -259,14 +290,16 @@ function createNotificationReader({
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
async function open(item) {
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) {
@@ -295,7 +328,7 @@ function createNotificationReader({
return true;
},
async loadOlder() {
if (!conversationPager || !selected) return false;
if (!conversationPager || !selected || offlineHydrated) return false;
const pager = conversationPager;
const version = loadVersion;
onStatus('Loading older messages…');
@@ -313,20 +346,21 @@ 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('Marking update read…');
onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
try {
await markRead(current.notification_id);
if (queueing) await queueRead(current.notification_id);
else 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);
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();
@@ -334,7 +368,7 @@ function createNotificationReader({
}
return { items: updated, next: next || null };
} catch (_error) {
onStatus('Could not mark update read. Retry.');
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
return false;
} finally {
marking = false;
@@ -383,11 +417,16 @@ function createNotificationReplier({
} catch (error) {
const status = Number(error?.status || 0);
if (authoredOutbox && (!status || status >= 500)) {
authoredOutbox.enqueue({
onStatus('Saving for background delivery…');
const admission = await authoredOutbox.enqueueDurably({
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
});
onStatus('Queued for sync when the connection returns.');
return { queued: true };
if (admission.background) {
onStatus('Queued for sync when the connection returns.');
return { queued: true };
}
onStatus('Saved for next launch; background delivery unavailable.');
return { queued: false, degraded: true };
}
onStatus('Could not send reply. Your draft is safe; retry.');
return false;
@@ -400,7 +439,7 @@ function createNotificationReplier({
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
let filtered = items;
if (selectedFilter === 'attention') filtered = items.filter((item) => item.has_update || item.is_review);
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
@@ -433,10 +472,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({
@@ -446,9 +542,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;
};
@@ -456,6 +555,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;
@@ -463,11 +563,36 @@ 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() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (index < 0) return false;
currentIndex = index;
report(items, index);
onOpen(items[index]);
return true;
},
resume() {
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) === 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;
@@ -560,7 +685,7 @@ function summarizeMyWork(items) {
function countMyWork(items) {
return {
all: items.length,
attention: items.filter((item) => item.has_update || item.is_review).length,
attention: items.filter(needsAttention).length,
issue: items.filter((item) => item.kind === 'issue').length,
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
review: items.filter((item) => item.is_review).length,
@@ -572,6 +697,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;
+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;
+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;
+83
View File
@@ -0,0 +1,83 @@
(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 readyKeys = new Set(items.filter(item => loadSavedDetail(login, 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 = loadSavedDetail(login, item);
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 = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
if (saved !== false) 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;
});
+97 -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,15 @@
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 pick(source, fields) {
const output = {};
@@ -25,18 +35,102 @@
return output;
}
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
function createOfflineWorkStore({
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
}) {
function enabled() {
try { return storage.getItem(ENABLED_KEY) === 'true'; }
catch (_) { return false; }
}
function clear() {
try { storage.removeItem(SNAPSHOT_KEY); }
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,
};
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;
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');
@@ -81,7 +175,7 @@
return { ...record.data, saved_at: record.saved_at };
}
return { enabled, setEnabled, save, load, clear };
return { enabled, setEnabled, 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) {
+45
View File
@@ -0,0 +1,45 @@
(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 close({ add = false } = {}) {
if (!current) return 'closed';
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() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
}
return { open, close, snapshot };
};
});
+85
View File
@@ -0,0 +1,85 @@
function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
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) {
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);
}
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();
}
function cancel() {
close();
return true;
}
function commit({ start: startAfterSave = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
if (save?.(ids) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
return 'saved';
}
function snapshot() {
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
}
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, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+61
View File
@@ -0,0 +1,61 @@
(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 deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
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 deletePrivateOutbox();
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
};
});
+5 -1
View File
@@ -139,7 +139,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;
+5 -2
View File
@@ -169,7 +169,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 +267,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) {
+187 -10
View File
@@ -1,10 +1,14 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v16';
const CACHE = 'stackchain-dashboard-shell-v72';
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',
@@ -13,57 +17,192 @@ const SHELL = [
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/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/comment-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/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.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/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 storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
}
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 cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
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();
}
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() {
const result = await issueSync.flush();
if (!result?.login || !result.receipts?.length ||
!await issueSync.getReceiptPreference?.(result.login)) return;
for (const receipt of result.receipts) {
const needsAttention = receipt.status === 'attention';
const title = 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.',
tag: 'stackchain-delivery-' + receipt.id,
data: { route: receipt.route },
});
}
}
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
@@ -76,7 +215,39 @@ self.addEventListener('activate', event => {
});
self.addEventListener('sync', event => {
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(issueSync.flush());
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
});
self.addEventListener('message', event => {
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));
}
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 => {
event.notification.close();
const route = String(event.notification.data?.route || '');
if (!route.startsWith('#/my-work/')) return;
const target = new URL(BASE + route, self.location.origin).href;
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
})());
});
self.addEventListener('fetch', event => {
@@ -86,18 +257,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;
+310 -7
View File
@@ -12,23 +12,136 @@
sessionStorage: root.sessionStorage,
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
location: root.location,
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 = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const 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 closeDevices = root.document.getElementById('close-active-devices');
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.';
}
};
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
devicesSheet.hidden = false;
closeDevices?.focus();
renderDevices();
});
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
devicesButton?.focus();
});
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, location,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
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');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
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 scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
async function publishOfflineLease(expiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
scheduleOfflineExpiry(expiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
}
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
@@ -42,7 +155,57 @@
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 sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
@@ -51,11 +214,72 @@
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) {
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 grant = await authorization.json().catch(() => ({}));
if (!grant.grant) return response;
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
retryHeaders.set('X-Step-Up-Grant', grant.grant);
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
}
}
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
expirationStarted = true;
const payload = await response.clone().json().catch(() => ({}));
if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else {
expirationStarted = false;
await handleSessionExpiry();
}
}
return response;
}
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)) return false;
await publishOfflineLease(payload.expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) {
scheduleOfflineExpiry(expiresAt);
return true;
}
if (expiresAt) await handleSessionExpiry();
return false;
}
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
@@ -68,17 +292,69 @@
} 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 deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
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 deletePrivateOutbox();
} 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 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 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' });
@@ -88,5 +364,32 @@
}
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData };
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;
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return {
fetch: sessionFetch,
signOut,
signOutAllDevices,
listActiveDevices,
revokeActiveDevice,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
};
});
+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']);
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 }) {
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();
workSession.complete();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+259
View File
@@ -0,0 +1,259 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, 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 }));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
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'].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 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, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+105
View File
@@ -0,0 +1,105 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.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 storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
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 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);
}
}
return write(unique);
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
return next.length !== ids.length && write(next);
}
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);
}
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,
};
}
return { identity, read, replace, 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;
+78
View File
@@ -0,0 +1,78 @@
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
let detail = null;
let update = null;
let claimedItem = null;
let request = null;
function state(value) {
onState?.(value);
}
function open(nextDetail, nextUpdate) {
detail = nextDetail;
update = nextUpdate;
claimedItem = null;
request = null;
state({
action: nextDetail?.issue?.claimable ? 'claim' : '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 => {
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);
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;
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;
}
return { open, act };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
-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"
}
]
}
+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()
+45
View File
@@ -0,0 +1,45 @@
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 = 9):
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", "")
if _quality(accepted, "gzip") > 0:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
else:
responder = IdentityResponder(self.app, self.minimum_size)
await responder(scope, receive, send)
+139 -19
View File
@@ -1,8 +1,10 @@
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
import asyncio
import base64
import hashlib
import hmac
import ipaddress
import json
import os
import secrets
@@ -11,32 +13,70 @@ from dataclasses import dataclass
from fastapi import Request
from src.session_store import SessionStore, SessionStoreError
SESSION_COOKIE = "stackchain_session"
CSRF_COOKIE = "stackchain_csrf"
DEFAULT_TTL_SECONDS = 8 * 60 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
INSECURE_LOCAL_MODE = "insecure-local"
@dataclass(frozen=True)
class Session:
session_id: str
csrf: str
expires_at: int
@dataclass(frozen=True)
class SessionVerification:
session: Session | None
reason: str | None = None
def access_token() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
def mode() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
def configuration_error() -> str | None:
configured_mode = mode()
if configured_mode == INSECURE_LOCAL_MODE:
return None
if configured_mode != OPERATOR_MODE:
return "unsupported authentication mode"
operator_secret = access_token()
signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
return "operator mode requires two sufficiently long secrets"
if hmac.compare_digest(operator_secret, signing_secret):
return "operator and session secrets must be independent"
return None
def enabled() -> bool:
return bool(access_token())
return mode() == OPERATOR_MODE and configuration_error() is None
def is_loopback_request(request: Request) -> bool:
host = request.client.host if request.client else ""
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host == "localhost"
def _secret() -> bytes:
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
if configured:
return configured.encode()
return hmac.new(
access_token().encode(), b"stackchain-dashboard-session-signing", hashlib.sha256
).digest()
if configuration_error() is not None or mode() != OPERATOR_MODE:
raise RuntimeError("Dashboard authentication is not configured")
return configured.encode()
def _encode(raw: bytes) -> str:
@@ -47,40 +87,120 @@ def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def issue_session(now: int | None = None) -> tuple[str, Session]:
def _session_store(now: int | None = None) -> SessionStore:
database = os.getenv(
"STACKCHAIN_SESSION_DB",
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
)
current = time.time if now is None else lambda: now
return SessionStore(database, clock=current)
def issue_session(
now: int | None = None, *, device_label: str = "This device"
) -> 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(csrf=secrets.token_urlsafe(24), expires_at=issued_at + max(1, ttl))
session = Session(
session_id=secrets.token_urlsafe(32),
csrf=secrets.token_urlsafe(24),
expires_at=issued_at + max(1, ttl),
)
payload = json.dumps(
{"csrf": session.csrf, "exp": session.expires_at},
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
separators=(",", ":"),
sort_keys=True,
).encode()
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(
session.session_id, session.expires_at, device_label=device_label
)
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(csrf=payload["csrf"], expires_at=int(payload["exp"]))
session = Session(
session_id=payload["sid"],
csrf=payload["csrf"],
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 or not isinstance(session.csrf, str) or not session.csrf:
return None
return session
if (
session.expires_at <= current
or not isinstance(session.session_id, str)
or not session.session_id
or not isinstance(session.csrf, str)
or not session.csrf
):
return SessionVerification(None)
if not _session_store(now).is_active(session.session_id, session.expires_at):
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
def request_session(request: Request) -> Session | None:
return verify_session(request.cookies.get(SESSION_COOKIE))
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 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:
+70
View File
@@ -0,0 +1,70 @@
"""Build the content-addressed browser shell served by the dashboard."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from pathlib import Path
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
@dataclass(frozen=True)
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_digest: str
runtime_name: str
page_sources: tuple[str, ...]
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")
return b"".join(chunks)
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")
runtime = _bundle(frontend_dir, sources)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
dashboard_html = SCRIPT_TAG.sub("", source_html)
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{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",
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
)
worker = re.sub(
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
worker,
count=1,
)
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_digest=digest,
runtime_name=runtime_name,
page_sources=sources,
service_worker_source=worker,
)
+165 -2
View File
@@ -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."""
@@ -261,8 +269,48 @@ 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 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 = {
@@ -680,6 +728,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 +773,7 @@ async def notification_detail(thread_id: int) -> dict:
else "",
"url": latest_url,
},
"issue": issue,
"conversation": conversation,
}
@@ -1038,6 +1102,90 @@ 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 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}",
@@ -1470,6 +1618,21 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
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:
+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,
}
+149
View File
@@ -0,0 +1,149 @@
"""Durable throttling state for operator sign-in attempts."""
import hashlib
import ipaddress
import math
import sqlite3
from pathlib import Path
from typing import Callable
class LoginAttemptStoreError(RuntimeError):
"""Raised when sign-in throttling state cannot be accessed safely."""
def client_source(peer_host: str, forwarded_for: str, trusted_proxy_cidrs: str) -> str:
"""Resolve a canonical client IP without trusting arbitrary forwarding headers."""
try:
peer = ipaddress.ip_address(peer_host)
trusted = [
ipaddress.ip_network(value.strip())
for value in trusted_proxy_cidrs.split(",")
if value.strip()
]
except ValueError:
return peer_host
if not any(peer in network for network in trusted):
return str(peer)
try:
forwarded = [
ipaddress.ip_address(value.strip())
for value in forwarded_for.split(",")
if value.strip()
]
except ValueError:
return str(peer)
for address in reversed(forwarded):
if not any(address in network for network in trusted):
return str(address)
return str(forwarded[0]) if forwarded else str(peer)
class LoginAttemptStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
max_failures: int,
window_seconds: int,
max_entries: int = 10_000,
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.max_failures = max(1, max_failures)
self.window_seconds = max(1, window_seconds)
self.max_entries = max(1, max_entries)
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _digest(source: str) -> str:
return hashlib.sha256(source.encode()).hexdigest()
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 login_attempts (
source_hash TEXT PRIMARY KEY,
failures INTEGER NOT NULL,
window_started_at REAL NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def retry_after(self, source: str) -> int:
now = self.clock()
try:
with self._connect() as connection:
row = connection.execute(
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
(self._digest(source),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
if row is None or row[0] < self.max_failures:
return 0
remaining = row[1] + self.window_seconds - now
return max(0, math.ceil(remaining))
def record_failure(self, source: str) -> None:
now = self.clock()
source_hash = self._digest(source)
try:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"DELETE FROM login_attempts WHERE window_started_at + ? <= ?",
(self.window_seconds, now),
)
row = connection.execute(
"SELECT failures, window_started_at FROM login_attempts WHERE source_hash = ?",
(source_hash,),
).fetchone()
if row is None or row[1] + self.window_seconds <= now:
connection.execute(
"INSERT OR REPLACE INTO login_attempts VALUES (?, 1, ?)",
(source_hash, now),
)
else:
connection.execute(
"UPDATE login_attempts SET failures = failures + 1 WHERE source_hash = ?",
(source_hash,),
)
connection.execute(
"""
DELETE FROM login_attempts
WHERE source_hash NOT IN (
SELECT source_hash FROM login_attempts
ORDER BY window_started_at DESC, rowid DESC LIMIT ?
)
""",
(self.max_entries,),
)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
def clear(self, source: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM login_attempts WHERE source_hash = ?",
(self._digest(source),),
)
except (OSError, sqlite3.Error) as exc:
raise LoginAttemptStoreError(
"Sign-in throttling is temporarily unavailable"
) from exc
+781 -44
View File
File diff suppressed because it is too large. Load diff
+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,
+93
View File
@@ -0,0 +1,93 @@
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
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 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
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 _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})
+288
View File
@@ -0,0 +1,288 @@
"""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
class SessionStoreError(RuntimeError):
"""Raised when session state cannot be read or changed safely."""
@dataclass(frozen=True)
class ActiveDevice:
management_id: str
device_label: str
created_at: int
expires_at: int
current: bool
class SessionStore:
def __init__(
self,
path: str | Path,
*,
clock: Callable[[], float],
lock_timeout_seconds: float = 0.1,
) -> None:
self.path = Path(path)
self.clock = clock
self.lock_timeout_seconds = lock_timeout_seconds
@staticmethod
def _digest(session_id: str) -> str:
return hashlib.sha256(session_id.encode()).hexdigest()
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
try:
if initialize:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(
self.path, timeout=self.lock_timeout_seconds
)
else:
connection = sqlite3.connect(
f"{self.path.resolve().as_uri()}?mode=rw",
timeout=self.lock_timeout_seconds,
uri=True,
)
if initialize:
connection.execute(
"""
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
)
"""
)
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",
}
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(
"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, *, device_label: str = "This device"
) -> 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 <= ?", (now,)
)
connection.execute(
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at"
") VALUES (?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
secrets.token_urlsafe(18),
label,
now,
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def is_active(self, session_id: str, expires_at: int) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
row = connection.execute(
"SELECT expires_at 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
return row is not None and row[0] == expires_at and expires_at > now
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 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
+254
View File
@@ -0,0 +1,254 @@
"""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
)
"""
)
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": []}
return {"revision": int(row[0]), "ids": json.loads(row[1])}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, ids 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 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"])
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)
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)
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
(login, revision, json.dumps(ids, separators=(",", ":"))),
)
elif changed:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, json.dumps(ids, separators=(",", ":")), login),
)
self._record_operation(connection, login, operation_id)
return {"revision": revision, "ids": ids}
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 FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
ids = list(snapshot["ids"])
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"}:
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)
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 += 1 if changed else 0
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
(login, revision, serialized),
)
elif accepted:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, serialized, login),
)
return {
"revision": revision,
"ids": ids,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
}
+24 -11
View File
@@ -1,20 +1,22 @@
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse, HTMLResponse
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, HTMLResponse, Response
from src.frontend_bundle import build_frontend
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{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>
<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>Sign in</button><p id="status" role="status"></p></form></main>
<script>document.getElementById('sign-in').addEventListener('submit',async(event)=>{event.preventDefault();const status=document.getElementById('status');status.textContent='Signing in…';const access_token=new FormData(event.currentTarget).get('access_token');const response=await fetch('api/v1/session',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({access_token})});if(response.ok){location.replace('./');return}status.textContent='Sign-in failed. Check the token and try again.';event.currentTarget.reset()});</script></body></html>"""
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><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" 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 +28,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 +41,21 @@ 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) -> Response:
if digest != FRONTEND_BUILD.runtime_digest:
raise HTTPException(status_code=404, detail="Runtime revision not found")
return Response(
FRONTEND_BUILD.runtime_bytes,
media_type="application/javascript",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
+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(),
)
)
+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):
+204 -2
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,85 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
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_persists_and_delivers_idempotent_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'
}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued,calls,result,remaining:outbox.list()
}})));
"""
output = run_node(script)
assert output["queued"]["kind"] == "issue-close"
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["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -113,6 +192,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 +262,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 +338,50 @@ 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))});
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 events = [];
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
backgroundSync: {{
reconcile: async (_items, lane) => {{ events.push('mirror:' + lane); await gate; events.push('committed'); }},
requestSync: async () => events.push('registered'),
}},
}});
const admission = outbox.enqueueDurably({{
kind:'issue-comment',repository:'o/r',number:1,body:'Do not lose',operationId:'message-1'
}}).then(result => {{ events.push('confirmed'); return result; }});
Promise.resolve().then(async () => {{
const pending = events.slice(); release(); const result = await admission;
process.stdout.write(JSON.stringify({{pending,events,result,items:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["pending"] == ["mirror:authored"]
assert output["events"] == ["mirror:authored", "committed", "registered", "confirmed"]
assert output["result"]["durability"] == "background"
assert output["items"][0]["operationId"] == "message-1"
def test_authored_outbox_foreground_send_uses_atomic_background_delivery():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -217,7 +417,9 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
assert "const authoredOutbox = createAuthoredOutbox({" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "flushAuthoredOutbox();" in html
assert "authoredOutbox.enqueue" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
assert "authoredOutbox.discard(item.outbox_id)" in html
assert "if (result?.queued)" in html
+541 -1
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,91 @@ const fetchJson = async (url, options = {{}}) => {{
}
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))});
const queued = [
{{id:'capture-1',operationId:'capture-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Secret title',body:'Secret body',labelIds:[]}},
{{id:'reply-1',operationId:'reply-1',ownerLogin:'timmy',status:'queued',kind:'pull-comment',repository:'stackchain/web',number:8,body:'Secret reply'}},
{{id:'bad-1',operationId:'bad-1',ownerLogin:'timmy',status:'queued',repository:'stackchain/api',title:'Bad',body:'Secret failure',labelIds:[]}},
];
const store = {{
claimNext: async () => queued.shift() || null,
complete: async () => {{}}, release: async () => {{}}, fail: async () => {{}},
countBlocked: async () => 0,
}};
let issueMutations = 0;
const fetchJson = async (url) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
if (url.endsWith('/issues') && issueMutations++ === 0) return {{number:44,repository:'stackchain/api',title:'Secret title'}};
if (url.includes('/pulls/8/comments')) return {{id:91,body:'Secret reply'}};
const error = new Error('Sensitive validation detail'); error.status = 422; throw error;
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["login"] == "timmy"
assert output["receipts"] == [
{
"id": "capture-1",
"status": "confirmed",
"kind": "issue",
"route": "#/my-work/issue/stackchain/api/44",
},
{
"id": "reply-1",
"status": "confirmed",
"kind": "message",
"route": "#/my-work/pull/stackchain/web/8",
},
{
"id": "bad-1",
"status": "attention",
"kind": "issue",
"route": "#/my-work/drafts",
},
]
serialized = json.dumps(output["receipts"])
assert "Secret" not in serialized
assert "Sensitive" not in serialized
@pytest.mark.parametrize(
("item", "expected_url"),
[
@@ -118,6 +203,69 @@ 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_delivers_issue_closure_and_returns_actionable_receipt():
authored = {
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
}
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'}}:{{number:27,state:'closed'}};}};
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/dashboard/issues/27/close"
assert mutation["options"]["method"] == "PATCH"
assert mutation["options"]["headers"] == {
"Accept": "application/json", "Idempotency-Key": "close-op"
}
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["result"]["receipts"] == [{
"id": "close-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/issue/stackchain/dashboard/27",
}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -141,6 +289,80 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
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))});
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.setReceiptPreference('timmy', true);
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
const result={{
timmy:await store.getReceiptPreference('timmy'),
alexander:await store.getReceiptPreference('alexander'),
snapshot:await store.snapshot(),
blocked:await store.countBlocked('alexander'),
}};
await store.setReceiptPreference('timmy', false);
result.disabled=await store.getReceiptPreference('timmy');
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output == {
"timmy": True,
"alexander": False,
"snapshot": [
{
"id": "issue",
"ownerLogin": "timmy",
"status": "queued",
"outboxLane": "issue",
}
],
"blocked": 1,
"disabled": False,
}
def test_closed_app_sync_leaves_another_accounts_issue_queued():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -166,6 +388,44 @@ const fetchJson = async url => {{
assert output["result"]["blocked"] == 1
def test_session_expiry_during_delivery_releases_claim_without_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const queued = [
{{id:'capture-auth',operationId:'stable-key',ownerLogin:'timmy',repository:'o/r',title:'Keep me',body:'',labelIds:[]}},
{{id:'capture-later',operationId:'later-key',ownerLogin:'timmy',repository:'o/r',title:'Do not try yet',body:'',labelIds:[]}},
];
const state = {{released:[],failed:[],mutationKeys:[]}};
const store = {{
claimNext: async () => queued.shift() || null,
release: async id => state.released.push(id),
fail: async (id,message) => state.failed.push({{id,message}}),
countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
state.mutationKeys.push(options.headers['Idempotency-Key']);
const error = new Error('Authentication required'); error.status = 401; throw error;
}};
(async () => {{
let error = null;
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
catch (caught) {{ error = {{message:caught.message,status:caught.status}}; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output == {
"state": {
"released": ["capture-auth"],
"failed": [],
"mutationKeys": ["stable-key"],
},
"error": {"message": "Authentication required", "status": 401},
}
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -194,6 +454,203 @@ 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_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 claimed=await store.claimBatch('timmy', 4);
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
}})();
"""
output = run_node(script)
assert [item["id"] for item in output["claimed"]] == [
"issue-1", "message-1", "issue-2", "message-2"
]
assert all(item["status"] == "sending" for item in output["claimed"])
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -223,6 +680,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))});
@@ -359,6 +841,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
@@ -392,3 +919,16 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
assert "issueOutbox.reconcileBackground(records);" in html
assert "authoredOutbox.reconcileBackground(records);" in html
assert "if ('indexedDB' in window)" in html
@pytest.mark.anyio
async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in():
html = await dashboard()
assert 'id="delivery-receipts" type="checkbox"' in html
assert "deliveryReceipts.addEventListener('change', async () => {" in html
assert "await Notification.requestPermission()" in html
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
+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
+212
View File
@@ -0,0 +1,212 @@
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_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,
}
@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
+178
View File
@@ -63,3 +63,181 @@ 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_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.",
]
+846 -6
View File
@@ -1,13 +1,102 @@
import asyncio
import time
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from src import main
from src.session_store import SessionStoreError
from src.views import FRONTEND_BUILD
@pytest.fixture
def access_control(monkeypatch):
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
async def fresh_grant(client, action: str, target: str) -> str:
response = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": action,
"target": target,
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert response.status_code == 201
return response.json()["grant"]
@pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
called = False
async def user():
nonlocal called
called = True
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
health = await client.get("/healthz")
responses = [
await client.get("/readyz"),
await client.get("/login"),
await client.get("/"),
await client.get("/api/v1/context"),
]
assert health.status_code == 200
assert [response.status_code for response in responses] == [503, 503, 503, 503]
assert all(response.headers["cache-control"] == "no-store" for response in responses)
assert all(response.json() == {
"detail": "Dashboard authentication is not configured"
} for response in responses)
assert called is False
@pytest.mark.anyio
async def test_operator_mode_rejects_reused_or_incomplete_secrets(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "same-secret-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "same-secret-with-at-least-thirty-two-characters")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
reused = await client.get("/login")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
incomplete = await client.get("/login")
assert reused.status_code == 503
assert incomplete.status_code == 503
assert "same-secret" not in reused.text
@pytest.mark.anyio
async def test_insecure_local_mode_is_restricted_to_loopback(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
local_transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
remote_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
async with httpx.AsyncClient(transport=local_transport, base_url="http://test") as client:
local = await client.get("/login")
async with httpx.AsyncClient(transport=remote_transport, base_url="http://test") as client:
remote = await client.get("/login")
assert local.status_code == 200
assert remote.status_code == 403
assert remote.json() == {"detail": "Insecure local mode requires a loopback client"}
@pytest.mark.anyio
@@ -30,6 +119,43 @@ async def test_anonymous_private_request_is_rejected_before_gitea(access_control
assert called is False
@pytest.mark.anyio
async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fields(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get(
"/",
params={
"title": "Production crash",
"text": "Steps from the mobile app",
"url": "https://example.com/incidents/42",
"next": "https://evil.example/steal",
},
)
assert response.status_code == 303
login_query = parse_qs(urlsplit(response.headers["location"]).query)
assert login_query == {
"continue": [
"./?title=Production+crash&text=Steps+from+the+mobile+app&url="
"https%3A%2F%2Fexample.com%2Fincidents%2F42"
]
}
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_oversized_share_target_is_not_carried_through_login(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get(
"/", params={"title": "x" * 201, "text": "keep me"}
)
assert response.status_code == 303
assert response.headers["location"] == "login"
@pytest.mark.anyio
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -47,6 +173,43 @@ async def test_sign_in_creates_secure_session_without_echoing_access_token(acces
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
failures = [
await client.post("/api/v1/session", json={"access_token": "wrong"})
for _ in range(3)
]
blocked = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert [response.status_code for response in failures] == [401, 401, 401]
assert blocked.status_code == 429
assert blocked.json() == {"detail": "Too many sign-in attempts"}
assert blocked.headers["retry-after"].isdigit()
assert blocked.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_successful_sign_in_clears_prior_failures(access_control):
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
for _ in range(2):
await client.post("/api/v1/session", json={"access_token": "wrong"})
success = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
after_success = [
await client.post("/api/v1/session", json={"access_token": "wrong"})
for _ in range(3)
]
assert success.status_code == 200
assert [response.status_code for response in after_success] == [401, 401, 401]
@pytest.mark.anyio
async def test_authenticated_get_reaches_private_api(access_control, monkeypatch):
async def user():
@@ -72,7 +235,35 @@ async def test_authenticated_get_reaches_private_api(access_control, monkeypatch
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
async def test_session_status_reuses_the_middleware_validation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
original_store = main.dashboard_auth._session_store()
lookups = 0
class CountingStore:
def is_active(self, session_id, expires_at):
nonlocal lookups
lookups += 1
return original_store.is_active(session_id, expires_at)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
response = await client.get("/api/v1/session")
assert response.status_code == 200
payload = response.json()
assert payload["authenticated"] is True
assert isinstance(payload["expires_at"], int)
assert payload["expires_at"] > int(time.time())
assert response.headers["cache-control"] == "no-store"
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
@@ -80,14 +271,223 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
)
response = await client.get("/api/v1/session")
payload = response.json()
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/session")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert "expires_at" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
):
merge_calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
merge_calls.append((repository, number, expected_head_sha))
return {"number": number, "merged": True, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf_headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
missing = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
headers=csrf_headers,
)
authorized = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "merge_pull",
"target": "stackchain/api#7",
},
headers=csrf_headers,
)
grant_headers = {**csrf_headers, "X-Step-Up-Grant": authorized.json()["grant"]}
merged = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
headers=grant_headers,
)
replayed = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
headers=grant_headers,
)
assert missing.status_code == 428
assert missing.json() == {
"detail": {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "merge_pull",
"target": "stackchain/api#7",
}
}
assert authorized.status_code == 201
assert authorized.json()["expires_in"] == 90
assert merged.status_code == 200
assert replayed.status_code == 428
assert merge_calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_other_high_impact_routes_require_fresh_authorization_before_mutation(
access_control, monkeypatch
):
close_calls = []
async def assigned(repository, number):
return True
async def close(repository, number):
close_calls.append((repository, number))
return {"number": number, "state": "closed"}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
await laptop.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
remote = next(
item
for item in (await phone.get("/api/v1/sessions")).json()["devices"]
if not item["current"]
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
}
closed = await phone.patch(
"/api/v1/repos/stackchain/api/issues/7/close", headers=headers
)
revoked = await phone.delete(
f"/api/v1/sessions/{remote['management_id']}", headers=headers
)
revoked_all = await phone.delete("/api/v1/sessions", headers=headers)
laptop_still_active = await laptop.get("/api/v1/session")
assert [closed.status_code, revoked.status_code, revoked_all.status_code] == [428, 428, 428]
assert [closed.json()["detail"]["action"], revoked.json()["detail"]["action"], revoked_all.json()["detail"]["action"]] == [
"close_issue", "revoke_device", "revoke_all_sessions"
]
assert close_calls == []
assert laptop_still_active.status_code == 200
@pytest.mark.anyio
async def test_operator_can_review_and_revoke_one_remote_device(access_control):
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "Pixel <script>",
},
)
await laptop.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "Work laptop",
},
)
listed = await laptop.get("/api/v1/sessions")
devices = listed.json()["devices"]
phone_device = next(device for device in devices if device["device_label"] == "Pixel <script>")
current_device = next(device for device in devices if device["current"])
missing_csrf = await laptop.delete(
f"/api/v1/sessions/{phone_device['management_id']}"
)
grant = await fresh_grant(
laptop, "revoke_device", phone_device["management_id"]
)
revoked = await laptop.delete(
f"/api/v1/sessions/{phone_device['management_id']}",
headers={
"Origin": "https://test",
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
phone_status = await phone.get("/api/v1/session")
phone_page = await phone.get("/", follow_redirects=False)
laptop_status = await laptop.get("/api/v1/session")
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert current_device["device_label"] == "Work laptop"
assert missing_csrf.status_code == 403
assert revoked.json() == {"revoked": True, "current_session": False}
assert phone_status.status_code == 401
assert phone_status.json() == {
"detail": "Authentication required",
"code": "session_revoked",
}
assert phone_page.status_code == 303
assert phone_page.headers["location"] == "login?reason=session-revoked"
assert laptop_status.status_code == 200
assert "session_hash" not in listed.text
assert "csrf" not in listed.text
@pytest.mark.anyio
async def test_active_device_labels_are_bounded_at_sign_in(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "x" * 65,
},
)
assert response.status_code == 422
@pytest.mark.anyio
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
calls = 0
@@ -145,6 +545,446 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_sign_out_all_devices_revokes_every_existing_session(access_control):
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
await laptop.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
grant = await fresh_grant(phone, "revoke_all_sessions", "all")
response = await phone.delete(
"/api/v1/sessions",
headers={
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
phone_private = await phone.get("/api/v1/background-identity")
laptop_private = await laptop.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {
"authenticated": False,
"all_sessions_revoked": True,
"clear_private_device_data": True,
}
assert phone_private.status_code == 401
assert laptop_private.status_code == 401
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await client.delete(
"/api/v1/sessions",
headers={
"Origin": "https://evil.example",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
session = await client.get("/api/v1/session")
assert response.status_code == 403
assert response.headers.get_list("set-cookie") == []
assert session.status_code == 200
assert session.json()["authenticated"] is True
@pytest.mark.anyio
async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
grant = await fresh_grant(client, "revoke_all_sessions", "all")
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def consume_step_up(self, grant, session_id, *, action, target):
return True
def revoke_all(self):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/sessions",
headers={
"Origin": "https://test",
"X-CSRF-Token": csrf,
"X-Step-Up-Grant": grant,
},
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
captured = client.cookies["stackchain_session"]
csrf = client.cookies["stackchain_csrf"]
async with httpx.AsyncClient(
transport=transport,
base_url="https://test",
headers={"Cookie": f"stackchain_session={captured}"},
) as replay:
before = await replay.get("/api/v1/background-identity")
await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
async with httpx.AsyncClient(
transport=transport,
base_url="https://test",
headers={"Cookie": f"stackchain_session={captured}"},
) as replay:
after = await replay.get("/api/v1/background-identity")
assert before.status_code == 200
assert after.status_code == 401
assert after.headers["cache-control"] == "no-store"
assert calls == 1
@pytest.mark.anyio
async def test_session_registry_latency_does_not_block_the_event_loop(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class SlowStore:
def is_active(self, session_id, expires_at):
time.sleep(0.15)
return True
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.01)
heartbeat_elapsed = time.perf_counter() - started
response = await private_request
assert response.status_code == 200
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
access_control, monkeypatch
):
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.15)
return 17
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post("/api/v1/session", json={"access_token": "wrong"})
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.01)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 429
assert response.headers["retry-after"] == "17"
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
access_control, monkeypatch
):
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def record_failure(self, source):
time.sleep(0.15)
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post("/api/v1/session", json={"access_token": "wrong"})
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 401
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
access_control, monkeypatch
):
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def clear(self, source):
time.sleep(0.15)
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 200
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_session_activation_does_not_block_the_event_loop(
access_control, monkeypatch
):
class Attempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def clear(self, source):
return None
class SlowStore:
def activate(self, session_id, expires_at, **kwargs):
time.sleep(0.15)
monkeypatch.setattr(main, "_login_attempt_store", lambda: Attempts())
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.13", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 200
assert any(
"stackchain_session=" in value
for value in response.headers.get_list("set-cookie")
)
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_single_session_revocation_does_not_block_the_event_loop(
access_control, monkeypatch
):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
csrf = client.cookies["stackchain_csrf"]
class SlowStore:
def is_active(self, session_id, expires_at):
time.sleep(0.02)
return True
def revoke(self, session_id):
time.sleep(0.15)
monkeypatch.setattr(
main.dashboard_auth, "_session_store", lambda now=None: SlowStore()
)
sign_out = asyncio.create_task(
client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_out
assert response.status_code == 200
assert response.json()["authenticated"] is False
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_session_registry_read_failure_fails_closed_before_gitea(
access_control, monkeypatch
):
called = False
async def user():
nonlocal called
called = True
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class BrokenStore:
def is_active(self, session_id, expires_at):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.get("/api/v1/background-identity")
health = await client.get("/healthz")
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert "database path" not in response.text
assert called is False
assert health.status_code == 200
@pytest.mark.anyio
async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch):
class BrokenStore:
def activate(self, session_id, expires_at, **kwargs):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_logout_registry_failure_does_not_claim_revocation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_public_routes_skip_session_registry_validation(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
class BrokenStore:
def is_active(self, session_id, expires_at):
raise SessionStoreError("public routes must not read the registry")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
login = await client.get("/login")
manifest = await client.get("/manifest.webmanifest")
static = await client.get("/static/session.js")
runtime = await client.get("/" + FRONTEND_BUILD.runtime_name)
assert signed_in.status_code == 200
assert [login.status_code, manifest.status_code, static.status_code, runtime.status_code] == [
200,
200,
200,
200,
]
assert runtime.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
async def test_public_routes_remain_available_and_readiness_hides_identity(access_control, monkeypatch):
async def user():
+419 -6
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: '' }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], 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,14 +28,38 @@ 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); }} }},
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; }},
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
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); }});
"""
@@ -41,6 +67,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(
"""
@@ -52,6 +232,164 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
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};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
]
assert result["leaseDelay"] > 0
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"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
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"]
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"]
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_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_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(
"""
@@ -70,6 +408,76 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
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_authenticated_dashboard_load_requests_queued_delivery_resume():
result = run_session_scenario(
"""
await boundary.resumeQueuedWork();
process.stdout.write(JSON.stringify(state));
"""
)
assert result["workerMessages"] == [{"type": "stackchain-resume-outbox"}]
@pytest.mark.anyio
@@ -79,3 +487,8 @@ 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"' in html
assert '#sign-out-all { min-height:44px; }' in html
+85 -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,84 @@ 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_queued_issue_closure_for_retry_or_discard():
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:'attention',error:'Closure rejected',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"] == "Issue closure needs attention"
assert output["outbox_kind"] == "issue-close"
assert output["title"] == "stackchain/dashboard#27"
assert output["preview"] == "Closure rejected"
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
assert output["quarantined"] is False
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))});
@@ -159,7 +237,12 @@ 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 "closureOutbox ? 'Open issue' : 'Open message'" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" 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
+120
View File
@@ -0,0 +1,120 @@
import gzip
import re
import shutil
from pathlib import Path
import httpx
import pytest
from src import main
from src.frontend_bundle import build_frontend
from src.views import dashboard, 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 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
@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"]) <= 100 * 1024
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@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)
html = await dashboard()
bundle = await runtime_bundle(build.runtime_digest)
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
+5 -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,7 @@ 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},
"conversation": {
"comments": [{
"id": 9,
+18
View File
@@ -71,3 +71,21 @@ def test_independent_connections_atomically_reserve_one_operation(tmp_path):
states = list(executor.map(reserve, ledgers))
assert sorted(states) == ["pending", "reserved"]
def test_stale_pending_reservation_becomes_uncertain_without_consuming_capacity(tmp_path):
now = [1_000.0]
ledger = IdempotencyLedger(
tmp_path / "orphaned.sqlite3",
ttl_seconds=10,
max_entries=1,
clock=lambda: now[0],
)
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
assert ledger.reserve("orphan", fingerprint).state == "reserved"
now[0] += 11
assert ledger.reserve("orphan", fingerprint).state == "uncertain"
assert ledger.reserve("unrelated", ("issue-comment", "stackchain/api", 8, "Continue")).state == "reserved"
assert ledger.reserve("orphan", ("issue-comment", "stackchain/api", 7, "Changed")).state == "conflict"
+212
View File
@@ -0,0 +1,212 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
def run_install_scenario(scenario: str) -> dict:
script = f"""
const createInstallApp = require({json.dumps(str(INSTALL_APP))});
class FakeTarget {{
constructor() {{ this.listeners = {{}}; }}
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
dispatch(name, event = {{}}) {{ for (const callback of this.listeners[name] || []) callback(event); }}
}}
class FakeButton extends FakeTarget {{
constructor() {{ super(); this.disabled = false; }}
click() {{ this.dispatch('click', {{currentTarget:this}}); }}
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_native_install_prompt_is_revealed_by_eligibility_and_invoked_once():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const install = new FakeButton();
const dismiss = new FakeButton();
const guidance = {hidden:true};
const status = {textContent:''};
let promptCalls = 0;
const app = createInstallApp({
window:windowTarget, card, installButton:install, dismissButton:dismiss,
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {
preventDefault(){},
prompt:async () => { promptCalls += 1; },
userChoice:Promise.resolve({outcome:'accepted'}),
});
const shown = !card.hidden;
install.click();
await new Promise(resolve => setImmediate(resolve));
install.click();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({shown, promptCalls, hidden:card.hidden, status:status.textContent}));
"""
)
assert result == {
"shown": True,
"promptCalls": 1,
"hidden": True,
"status": "Stackchain was added to your device.",
}
def test_declined_native_install_hides_the_spent_choice_and_announces_it():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const install = new FakeButton();
const status = {textContent:''};
const app = createInstallApp({
window:windowTarget, card, installButton:install, dismissButton:new FakeButton(),
guidance:{hidden:true}, status, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {
preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'dismissed'}),
});
install.click();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({hidden:card.hidden, disabled:install.disabled, status:status.textContent}));
"""
)
assert result == {
"hidden": True,
"disabled": False,
"status": "Installation was not completed.",
}
def test_ios_safari_gets_manual_add_to_home_screen_guidance():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const install = new FakeButton(); install.hidden = false;
const dismiss = new FakeButton();
const guidance = {hidden:true};
const status = {textContent:''};
const app = createInstallApp({
window:windowTarget, card, installButton:install, dismissButton:dismiss,
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>true,
});
app.start();
process.stdout.write(JSON.stringify({cardHidden:card.hidden, installHidden:install.hidden, guidanceHidden:guidance.hidden}));
"""
)
assert result == {
"cardHidden": False,
"installHidden": True,
"guidanceHidden": False,
}
def test_standalone_launch_never_promotes_installation():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const app = createInstallApp({
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>true, isIosSafari:()=>true,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
process.stdout.write(JSON.stringify({hidden:card.hidden}));
"""
)
assert result == {"hidden": True}
def test_dismissal_is_remembered_and_suppresses_later_promotion():
result = run_install_scenario(
"""
const values = new Map();
const storage = {getItem:key => values.get(key) || null, setItem:(key, value) => values.set(key, value)};
const firstWindow = new FakeTarget();
const firstCard = {hidden:true};
const firstDismiss = new FakeButton();
const first = createInstallApp({
window:firstWindow, card:firstCard, installButton:new FakeButton(), dismissButton:firstDismiss,
guidance:{hidden:true}, status:{textContent:''}, storage,
isStandalone:()=>false, isIosSafari:()=>true,
});
first.start();
firstDismiss.click();
const secondCard = {hidden:true};
const second = createInstallApp({
window:new FakeTarget(), card:secondCard, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage,
isStandalone:()=>false, isIosSafari:()=>true,
});
second.start();
process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:values.size, secondHidden:secondCard.hidden}));
"""
)
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
def test_appinstalled_hides_an_visible_native_promotion():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const app = createInstallApp({
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
const shown = !card.hidden;
windowTarget.dispatch('appinstalled');
process.stdout.write(JSON.stringify({shown, hidden:card.hidden}));
"""
)
assert result == {"shown": True, "hidden": True}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_touch_safe_install_promotion():
html = await dashboard()
assert 'id="install-app-card"' in html
assert 'id="install-app"' in html
assert 'id="dismiss-install-app"' in html
assert 'id="install-app-guidance"' in html
assert 'Share, then choose <strong>Add to Home Screen</strong>' in html
assert '.install-app-card button { min-height:44px;' in html
assert '<script src="static/install-app.js"></script>' in html
assert 'createInstallApp({' in html
assert "window.matchMedia('(display-mode: standalone)').matches" in html
assert "navigator.standalone === true" in html
assert "isIosSafari" in html
+318 -1
View File
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
async def repository_access_from(loader, repository):
repositories = await loader()
return next(
(item for item in repositories if item.get("full_name") == repository), None
)
@pytest.mark.anyio
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
requests = []
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
}
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -373,6 +388,118 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.url.path, dict(request.url.params)))
if request.url.path.endswith("/user/repos"):
return httpx.Response(
200,
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
headers={"X-Total-Count": "51"},
)
if request.url.path.endswith("/repos/stackchain/later"):
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
page = await gitea_proxy.repo_page(page=2, limit=50)
first_page = await gitea_proxy.repos()
accessible = await gitea_proxy.repository_access("stackchain/later")
missing = await gitea_proxy.repository_access("stackchain/missing")
finally:
await gitea_proxy.stop_client()
assert page == {
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
"page": 2,
"total": 51,
"has_more": False,
}
assert accessible["full_name"] == "stackchain/later"
assert missing is None
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
assert calls == [
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
("/api/v1/repos/stackchain/later", {}),
("/api/v1/repos/stackchain/missing", {}),
]
@pytest.mark.anyio
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
access_calls = []
create_calls = []
async def page(page, limit):
assert (page, limit) == (2, 50)
return {
"items": [{
"id": 51, "name": "later", "full_name": "stackchain/later",
"description": "", "html_url": "https://forge.example/stackchain/later",
"updated_at": "2026-08-09T12:00:00Z",
}],
"page": 2, "total": 51, "has_more": False,
}
async def access(repository):
access_calls.append(repository)
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
async def user():
return {"login": "timmy"}
async def create(repository, title, body, assignee, label_ids):
create_calls.append((repository, title, assignee))
return {
"id": 403, "number": 403, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-09T12:00:00Z",
"url": "https://forge.example/stackchain/later/issues/403",
}
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repositories?page=2&limit=50")
created = await client.post(
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
)
assert listed.status_code == 200
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
assert listed.json()["has_more"] is False
assert created.status_code == 201
assert access_calls == ["stackchain/later"]
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
def test_context_exposes_truthful_repository_pagination():
repositories = gitea_proxy.RepositoryItems(
[{
"id": 1, "name": "api", "full_name": "stackchain/api",
"description": "", "html_url": "https://forge.example/stackchain/api",
"updated_at": "2026-08-09T12:00:00Z",
}],
{"page": 1, "total": 51, "has_more": True},
)
payload = main._context_payload(
{"id": 2, "login": "timmy"}, repositories, [], []
)
assert payload["repository_pagination"] == {
"page": 1, "total": 51, "has_more": True
}
@pytest.mark.anyio
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
calls = []
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-concurrent"}
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-create-201"}
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
@@ -915,7 +1074,7 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
assert response.status_code == 409
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
"error": "This issue was already claimed or is no longer open."
}
@@ -955,6 +1114,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
assert result["available"] is False
@pytest.mark.anyio
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
async def handler(request):
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
return httpx.Response(200, json=[
{"login": "timmy", "full_name": "Timmy"},
{"login": "alex", "full_name": "Alexander"},
{"login": "casey", "full_name": ""},
{"login": ""},
{"full_name": "Missing login"},
"malformed",
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert result == [
{"login": "alex", "name": "Alexander"},
{"login": "casey", "name": "casey"},
]
@pytest.mark.anyio
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/assignees"):
return httpx.Response(200, json=[
{"login": "alex", "full_name": "Alexander"},
{"login": "casey", "full_name": "Casey"},
])
if request.method == "GET":
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}, {"login": "casey"}],
})
assert request.content == b'{"assignees":["casey","alex"]}'
return httpx.Response(200, json={
"number": 17, "state": "open",
"assignees": [{"login": "casey"}, {"login": "alex"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 17, "state": "open",
"assignees": ["casey", "alex"], "recipient": "alex",
}
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/assignees"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
@@ -981,6 +1210,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
assert main._available_issue_snapshot_created_at is None
@pytest.mark.anyio
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def candidates(repository):
calls.append(("candidates", repository))
return [{"login": "alex", "name": "Alexander"}]
async def handoff(repository, number, recipient):
calls.append(("handoff", repository, number, recipient))
return {
"repository": repository, "number": number, "state": "open",
"assignees": [recipient], "recipient": recipient,
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get(
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
)
transferred = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/handoff",
json={"recipient": "alex"},
)
assert listed.status_code == 200
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
assert transferred.status_code == 200
assert transferred.json()["recipient"] == "alex"
assert listed.headers["cache-control"] == "no-store"
assert transferred.headers["cache-control"] == "no-store"
assert calls == [
("assigned", "stackchain/api", 17),
("candidates", "stackchain/api"),
("handoff", "stackchain/api", 17, "alex"),
]
@pytest.mark.anyio
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
async def handoff(_repository, _number, _recipient):
raise gitea_proxy.IssueNotAvailableError("stale")
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/handoff",
json={"recipient": "alex"},
)
assert response.status_code == 409
assert response.json() == {
"error": "The issue or recipient changed. Reload before handing off."
}
@pytest.mark.anyio
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
called = False
async def assigned(_repository, _number):
return False
async def candidates(_repository):
nonlocal called
called = True
return []
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
)
assert response.status_code == 404
assert called is False
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
+217 -5
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" / "issue-outbox.js"
@@ -68,7 +68,57 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
]
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
assert all(item["status"] == "queued" for item in output["items"])
assert output["stored"]["version"] == 2
assert output["stored"]["version"] == 3
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
script = f"""
const createIssueOutbox = 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 = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1',
fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}),
}});
outbox.enqueue({{
repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start'
}});
const reloaded = createIssueOutbox({{
storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}})
}});
reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')),
result,
}})));
"""
output = run_node(script)
assert output["persisted"]["version"] == 3
completion = output["result"]["completions"][0]
assert completion["intent"] == "create-and-start"
assert completion["ownerLogin"] == "timmy"
assert completion["operationId"] == "create-start-1"
assert completion["issue"]["number"] == 389
def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery():
script = f"""
const createIssueOutbox = 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 outbox=createIssueOutbox({{
storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence)
}});
const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}});
const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}});
const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}});
process.stdout.write(JSON.stringify({{started,ordinary}}));
"""
output = run_node(script)
assert output["started"]["completionIntent"] == "create-and-start"
assert "completionIntent" not in output["ordinary"]
assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-1"
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
@@ -110,9 +160,10 @@ const createIssueOutbox = 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)}};
let invalid = true;
let operationSequence = 0;
const calls = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
fetchJson:async (_url, options) => {{
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
@@ -132,12 +183,74 @@ outbox.flush('timmy').then(async () => {{
assert output["attention"]["status"] == "attention"
assert output["attention"]["error"] == "Title is invalid"
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
assert [call["key"] for call in output["calls"]] == ["edit-1", "edit-2"]
assert output["calls"][1]["body"]["title"] == "Fixed title"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_issue_outbox_rekeys_only_after_user_retries_uncertain_delivery():
script = f"""
const createIssueOutbox = 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 = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'operation-' + (++sequence),
fetchJson:async (_url, options) => {{
const key=options.headers['Idempotency-Key']; calls.push(key);
if (key === 'operation-1') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
return {{number:319}};
}},
}});
const item=outbox.enqueue({{repository:'o/r',title:'Possibly sent',body:'Check first'}});
(async()=>{{
await outbox.flush('timmy'); const attention=outbox.list()[0];
await outbox.retry(item.id,'timmy');
process.stdout.write(JSON.stringify({{attention,calls,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["attention"]["status"] == "attention"
assert output["attention"]["deliveryState"] == "uncertain"
assert output["calls"] == ["operation-1", "operation-2"]
assert output["remaining"] == []
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
script = f"""
const createIssueOutbox = 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 sequence = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'payload-' + (++sequence),
}});
const queued = outbox.enqueue({{
repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09'
}});
const unchanged = outbox.update(queued.id, {{...queued}});
const fields = [
['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'],
['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'],
];
const edits = [];
for (const [field, value] of fields) {{
const current = outbox.list()[0];
edits.push(outbox.update(queued.id, {{...current, [field]:value}}));
}}
process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}}));
"""
output = run_node(script)
assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1"
assert [item["operationId"] for item in output["edits"]] == [
"payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7"
]
assert output["final"]["milestoneId"] == 4
assert output["final"]["dueDate"] == "2026-08-10"
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -186,6 +299,70 @@ setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
assert output["syncs"] == 1
def test_issue_outbox_confirms_durable_admission_only_after_mirror_and_sync():
script = f"""
const createIssueOutbox = 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 releaseMirror; let releaseSync;
const mirrorGate = new Promise(resolve => releaseMirror = resolve);
const syncGate = new Promise(resolve => releaseSync = resolve);
const events = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-1',
backgroundSync: {{
reconcile: async items => {{ events.push('mirror-start'); await mirrorGate; events.push('mirror-committed'); }},
requestSync: async () => {{ events.push('sync-start'); await syncGate; events.push('sync-registered'); }},
}},
}});
const admission = outbox.enqueueDurably({{repository:'stackchain/api',title:'Keep this',body:'Draft'}})
.then(result => {{ events.push('confirmed'); return result; }});
Promise.resolve().then(async () => {{
const beforeMirror = events.slice();
releaseMirror(); await new Promise(resolve => setTimeout(resolve, 0));
const beforeSync = events.slice();
releaseSync();
const result = await admission;
process.stdout.write(JSON.stringify({{beforeMirror,beforeSync,events,result,items:outbox.list()}}));
}});
"""
output = run_node(script)
assert output["beforeMirror"] == ["mirror-start"]
assert output["beforeSync"] == ["mirror-start", "mirror-committed", "sync-start"]
assert output["events"][-1] == "confirmed"
assert output["result"]["background"] is True
assert output["result"]["item"]["id"] == "durable-1"
assert output["items"][0]["title"] == "Keep this"
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
script = f"""
const createIssueOutbox = 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 = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'foreground-1',
backgroundSync: {{
reconcile: async () => {{}},
requestSync: async () => {{ throw new Error('Background Sync unavailable'); }},
}},
}});
outbox.enqueueDurably({{repository:'stackchain/api',title:'Retain me',body:'Draft'}}).then(result =>
process.stdout.write(JSON.stringify({{
background:result.background, durability:result.durability,
error:result.error.message, item:result.item, persisted:outbox.list()
}}))
);
"""
output = run_node(script)
assert output["background"] is False
assert output["durability"] == "foreground-only"
assert output["error"] == "Background Sync unavailable"
assert output["persisted"] == [output["item"]]
def test_foreground_delivery_uses_same_atomic_background_claim():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -257,13 +434,48 @@ process.stdout.write(JSON.stringify(outbox.list()));
]
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{
id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start'
}}]}}));
const outbox=createIssueOutbox({{storage}});
outbox.reconcileBackground([{{
id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start',
deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}}
}}]);
const blocked=outbox.pendingCompletions('alexander');
const pending=outbox.pendingCompletions('timmy');
const retained=outbox.list();
outbox.completeIntent('resume','timmy');
process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}}));
"""
output = run_node(script)
assert output["blocked"] == []
assert output["pending"] == [{
"id": "resume", "intent": "create-and-start", "ownerLogin": "timmy",
"operationId": "resume",
"issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"},
}]
assert output["retained"][0]["status"] == "completion"
assert output["remaining"] == []
@pytest.mark.anyio
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
html = await dashboard()
assert '<script src="static/issue-outbox.js"></script>' in html
assert "const issueOutbox = createIssueOutbox({" in html
assert "issueOutbox.enqueue(captureDraft)" in html
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
assert "Saving for background delivery…" in html
assert "Saved for next launch; background delivery unavailable." in html
assert "throw new Error('Background Sync unavailable')" in html
assert "issueOutbox.retry(queued.id, activeFlushLogin)" in html
assert "navigator.onLine" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
+134
View File
@@ -0,0 +1,134 @@
import json
import subprocess
from pathlib import Path
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_exact_local_time_is_validated_and_converted_to_an_instant():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const picker = createLaterPicker({{
now: () => new Date(2026, 7, 9, 14, 0, 0),
}});
const valid = picker.parse('2026-08-09T16:30');
const past = picker.parse('2026-08-09T13:59');
const invalid = picker.parse('2026-02-30T12:00');
process.stdout.write(JSON.stringify({{
valid: {{ok:valid.ok, local:[valid.value.getFullYear(), valid.value.getMonth() + 1, valid.value.getDate(), valid.value.getHours(), valid.value.getMinutes()]}},
past, invalid,
}}));
"""
output = run_node(script)
assert output["valid"] == {"ok": True, "local": [2026, 8, 9, 16, 30]}
assert output["past"] == {
"ok": False,
"message": "Choose a future date and time.",
}
assert output["invalid"] == {
"ok": False,
"message": "Choose a valid local date and time.",
}
def test_picker_uses_history_for_cancel_and_confirms_only_valid_future_time():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const listeners = {{}};
const states = [];
const confirmed = [];
let focused = 0;
const history = {{
state: {{workRoute:'issue'}},
pushState(state) {{ this.state = state; states.push(['push', state.laterPicker]); }},
back() {{ states.push(['back']); const previous = {{workRoute:'issue'}}; this.state = previous; listeners.popstate({{state:previous}}); }},
}};
const picker = createLaterPicker({{
now: () => new Date(2026, 7, 9, 14, 0, 0), history,
eventTarget: {{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
onState: state => states.push([state.open, state.message || '']),
onConfirm: (item, value, context) => {{ confirmed.push([item.number, value.toISOString(), context]); return true; }},
}});
picker.start();
picker.open({{number:17}}, {{focus:() => {{ focused += 1; }}}}, 'detail');
const invalid = picker.submit('2026-08-09T13:00');
const valid = picker.submit('2026-08-09T16:30');
picker.submit('2026-08-09T17:30');
process.stdout.write(JSON.stringify({{invalid, valid, confirmed, focused, current:picker.current(), states}}));
"""
output = run_node(script)
assert output["invalid"] is False
assert output["valid"] is True
assert output["confirmed"] == [[17, "2026-08-09T16:30:00.000Z", "detail"]]
assert output["focused"] == 1
assert output["current"] is False
assert [False, "Choose a future date and time."] not in output["states"]
assert [True, "Choose a future date and time."] in output["states"]
assert ["back"] in output["states"]
def test_picker_runs_detail_cleanup_only_after_its_history_entry_closes():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const listeners = {{}};
const order = [];
const history = {{
state: {{workRoute:'issue'}},
pushState(state) {{ this.state = state; }},
back() {{
order.push('picker-back');
this.state = {{workRoute:'issue'}};
listeners.popstate({{state:this.state}});
}},
}};
const picker = createLaterPicker({{
now:() => new Date(2026, 7, 9, 14, 0), history,
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
onConfirm:() => () => order.push('detail-close'),
}});
picker.start();
picker.open({{number:17}}, null, 'detail');
const saved = picker.submit('2026-08-09T16:30');
process.stdout.write(JSON.stringify({{saved, order}}));
"""
output = run_node(script)
assert output == {"saved": True, "order": ["picker-back", "detail-close"]}
def test_picker_ignores_a_second_submit_while_browser_back_is_pending():
script = f"""
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
const listeners = {{}};
let popstate;
let confirmations = 0;
const history = {{
state:null,
pushState(state) {{ this.state = state; }},
back() {{ popstate = () => {{ this.state = {{}}; listeners.popstate({{state:this.state}}); }}; }},
}};
const picker = createLaterPicker({{
now:() => new Date(2026, 7, 9, 14, 0), history,
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
onConfirm:() => {{ confirmations += 1; return true; }},
}});
picker.start();
picker.open({{number:17}});
const first = picker.submit('2026-08-09T16:30');
const second = picker.submit('2026-08-09T16:30');
popstate();
process.stdout.write(JSON.stringify({{first, second, confirmations}}));
"""
output = run_node(script)
assert output == {"first": True, "second": False, "confirmations": 1}
+277
View File
@@ -0,0 +1,277 @@
import sqlite3
import httpx
import pytest
from src import main
from src.later_store import LaterStore
@pytest.mark.anyio
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
async def user():
return {"login": "timmy"}
class BusyStore:
def apply(self, *_args, **_kwargs):
raise sqlite3.OperationalError("database is busy")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
payload = main.LaterOperation(
operation_id="retry-me",
action="defer",
item_id="issue:r:1:",
wake_at="2026-08-10T09:00:00.000Z",
)
with pytest.raises(main.HTTPException) as raised:
await main.update_later_plan(payload)
assert raised.value.status_code == 503
assert raised.value.headers == {"Retry-After": "1"}
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
path = tmp_path / "later.sqlite3"
store = LaterStore(path)
deferred = store.apply(
"Timmy",
"op-1",
"defer",
"issue:stackchain/dashboard:363:",
wake_at="2026-08-10T09:00:00.000Z",
)
duplicate = store.apply(
"timmy",
"op-1",
"defer",
"issue:stackchain/dashboard:363:",
wake_at="2026-08-11T09:00:00.000Z",
)
assert duplicate == deferred == {
"revision": 1,
"records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
},
}
assert LaterStore(path).get("timmy") == deferred
assert store.get("alexander") == {"revision": 0, "records": {}}
assert store.apply(
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
) == {"revision": 2, "records": {}}
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
operations = [
{"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
{"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
]
result = store.apply_batch("timmy", operations)
assert result == {
"revision": 2,
"records": {},
"accepted_operation_ids": ["first", "second"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
replay = store.apply_batch("timmy", operations)
assert replay["revision"] == 2
assert replay["accepted_operation_ids"] == []
assert replay["duplicate_operation_ids"] == ["first", "second"]
def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
first = store.apply_batch("timmy", [{
"operation_id": "newer-device",
"action": "defer",
"item_id": "issue:r:1:",
"wake_at": "2026-08-12T09:00:00.000Z",
"base_revision": 0,
}])
replay = store.apply_batch("timmy", [
{
"operation_id": "stale-device",
"action": "defer",
"item_id": "issue:r:1:",
"wake_at": "2026-08-10T09:00:00.000Z",
"base_revision": 0,
},
{
"operation_id": "unrelated-item",
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-11T09:00:00.000Z",
"base_revision": 0,
},
])
assert first["revision"] == 1
assert replay == {
"revision": 2,
"records": {
"issue:r:1:": "2026-08-12T09:00:00.000Z",
"issue:r:2:": "2026-08-11T09:00:00.000Z",
},
"accepted_operation_ids": ["unrelated-item"],
"duplicate_operation_ids": [],
"rejected_operations": [
{"operation_id": "stale-device", "reason": "stale_intent"}
],
}
duplicate = store.apply_batch("timmy", [{
"operation_id": "stale-device",
"action": "restore",
"item_id": "issue:r:2:",
"base_revision": 2,
}])
assert duplicate["duplicate_operation_ids"] == ["stale-device"]
assert duplicate["revision"] == 2
assert duplicate["records"] == replay["records"]
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
path = tmp_path / "later.sqlite3"
with sqlite3.connect(path) as connection:
connection.execute(
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
"PRIMARY KEY (login, operation_id))"
)
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
)
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
for index in range(4):
store.apply(
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
wake_at="2026-08-10T09:00:00.000Z",
)
store.apply(
"alexander", "other", "defer", "issue:r:99:",
wake_at="2026-08-10T09:00:00.000Z",
)
with sqlite3.connect(path) as connection:
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
timmy = connection.execute(
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
).fetchall()
alexander = connection.execute(
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
).fetchall()
assert "created_at" in columns
assert timmy == [("op-2",), ("op-3",)]
assert alexander == [("other",)]
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
clock = [1_000.0]
path = tmp_path / "later.sqlite3"
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
store.apply("timmy", "old", "restore", "issue:r:1:")
store.apply("alexander", "other-old", "restore", "issue:r:2:")
clock[0] += 61
store.apply("timmy", "fresh", "restore", "issue:r:3:")
with sqlite3.connect(path) as connection:
timmy = connection.execute(
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
).fetchall()
alexander = connection.execute(
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
).fetchall()
assert timmy == [("fresh",)]
assert alexander == [("other-old",)]
@pytest.mark.anyio
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LATER_DB", str(tmp_path / "later.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
forbidden = await client.patch(
"/api/v1/later",
json={
"operation_id": "mobile-1",
"action": "defer",
"item_id": "issue:stackchain/dashboard:363:",
"wake_at": "2026-08-10T09:00:00.000Z",
},
)
changed = await client.patch(
"/api/v1/later",
json={
"operations": [
{"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
{"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
],
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
stale = await client.patch(
"/api/v1/later",
json={
"operations": [{
"operation_id": "offline-stale",
"action": "restore",
"item_id": "issue:stackchain/dashboard:363:",
"base_revision": 0,
}],
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
fetched = await client.get("/api/v1/later")
assert forbidden.status_code == 403
assert changed.status_code == 200
assert changed.json() == {
"revision": 2,
"records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
},
"accepted_operation_ids": ["mobile-1", "mobile-2"],
"duplicate_operation_ids": [],
"rejected_operations": [],
}
assert stale.json()["rejected_operations"] == [
{"operation_id": "offline-stale", "reason": "stale_intent"}
]
assert stale.json()["revision"] == 2
assert fetched.json() == {"revision": 2, "records": {
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
}}
assert fetched.headers["cache-control"] == "no-store"
+351
View File
@@ -0,0 +1,351 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
def run_node(script):
return json.loads(
subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
)
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const createCoordinator=require({json.dumps(str(COORDINATOR))});
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
let sequence=0,releaseFirst;const delivered=[];
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
"""
result = run_node(script)
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
assert len(result["delivered"]) == 2
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const prefix='stackchain.later-sync.v1.timmy.operation.';
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(...JSON.parse(options.body).operations);return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
"""
result = run_node(script)
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
assert result["pending"] == []
assert not any(".operation." in key for key in result["keys"])
def test_offline_deferral_replays_once_and_adopts_server_records():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values = new Map();
const requests = [];
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
const sync = createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
fetchJson:async (url,options={{}})=>{{
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
if (!options.method) return remote;
const operation=JSON.parse(options.body).operations[0];
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
return remote;
}},
onRemoteRecords:records=>{{globalThis.records=records}},
onStatus:status=>{{globalThis.status=status}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
}}));}})();
"""
result = run_node(script)
assert [request["url"] for request in result["requests"]] == [
"api/v1/later",
"api/v1/later",
]
assert result["requests"][0]["body"] == {"operations": [{
"operation_id": "offline-op",
"action": "defer",
"item_id": "issue:r:2:",
"wake_at": "2026-08-10T09:00:00.000Z",
"base_revision": 0,
}]}
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
assert result["status"] == "saved"
assert result["pending"] == []
def test_later_queue_expires_ancient_edits_before_replay():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const statuses=[];const requests=[];
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
now:()=>1_800_000_000_000,maxOfflineMs:1000,
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
const key=[...values.keys()].find(value=>value.includes('.operation.'));
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
"""
result = run_node(script)
assert result["requests"] == ["GET", "GET"]
assert ["expired", 1] in result["statuses"]
assert result["statuses"][-1] == ["expired", 1]
assert result["pending"] == []
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const requests=[];const statuses=[];const adopted=[];
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const sync=createLaterSync({{
storage,getLogin:()=> 'timmy',createOperationId:()=> 'offline-old',
fetchJson:async(_url,options={{}})=>{{
if(!options.method)return {{revision:3,records:{{'issue:r:1:':'2026-08-12T09:00:00.000Z'}}}};
const body=JSON.parse(options.body);requests.push(body);
return {{revision:4,records:{{'issue:r:1:':'2026-08-13T09:00:00.000Z'}},accepted_operation_ids:[],duplicate_operation_ids:[],rejected_operations:[{{operation_id:'offline-old',reason:'stale_intent'}}]}};
}},onRemoteRecords:records=>adopted.push(records),onStatus:(state,detail={{}})=>statuses.push([state,detail]),
}});
(async()=>{{await sync.flush();sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');await sync.flush();
process.stdout.write(JSON.stringify({{requests,statuses,adopted,pending:sync.pending()}}));}})();
"""
result = run_node(script)
assert result["requests"] == [{"operations": [{
"operation_id": "offline-old",
"action": "defer",
"item_id": "issue:r:1:",
"wake_at": "2026-08-10T09:00:00.000Z",
"base_revision": 3,
}]}]
assert result["statuses"][-1] == ["conflict", {"count": 1}]
assert result["adopted"][-1] == {
"issue:r:1:": "2026-08-13T09:00:00.000Z"
}
assert result["pending"] == []
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map();const requests=[];let sequence=0;
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,records:{{}},accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
sync.enqueue('restore','issue:r:2:');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
"""
result=run_node(script)
assert len(result["requests"]) == 1
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
assert result["pending"] == []
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values=new Map(); let sequence=0;
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
sync.enqueue('restore','issue:r:2:');
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
"""
assert run_node(script) == {
"pending": [
{
"operation_id": "op-2",
"action": "restore",
"item_id": "issue:r:2:",
"wake_at": None,
"base_revision": 0,
}
],
"status": "retrying",
}
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
const scheduledBeforeSwitch=timers.length;
login='alexander'; await timers[0].callback();
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
}})();
"""
assert run_node(script) == {
"scheduledBeforeSwitch": 1,
"timers": 1,
"requests": ["PATCH"],
"pendingForAlexander": [],
}
def test_change_queued_during_delivery_is_drained_before_flush_settles():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
fetchJson:async (_url,options={{}})=>{{
if (!options.method) return {{revision:0,records:{{}}}};
const operation=JSON.parse(options.body).operations[0]; actions.push(operation.action);
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
"""
assert run_node(script) == {
"actions": ["defer", "restore"],
"pending": [],
"records": {},
}
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
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 listeners=[];
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
"""
result = run_node(script)
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
script = f"""
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
const first=sync.migrate(records),second=sync.migrate(records);
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
"""
assert run_node(script) == {
"first": True,
"second": False,
"requests": ["PATCH"],
"pending": [],
}
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
script = f"""
const createLaterWork=require({json.dumps(str(LATER_WORK))});
const values=new Map(); const changes=[]; const expired=[];
let clock=new Date('2026-08-08T12:00:00Z');
const item={{kind:'issue',repository:'stackchain/api',number:17}};
const work=createLaterWork({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
onExpire:ids=>expired.push(ids),
}});
work.defer(item,new Date('2026-08-08T16:00:00Z'));
work.restore(item);
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
const remote=work.partition([item]);
clock=new Date('2026-08-09T09:00:01Z');
const awake=work.partition([item]);
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
"""
result = run_node(script)
assert result["changes"] == [
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
["restore", "issue:stackchain/api:17:", None],
]
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
assert result["expired"] == [["issue:stackchain/api:17:"]]
@pytest.mark.anyio
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
html = await dashboard()
assert '<script src="static/later-sync.js"></script>' in html
assert 'id="later-sync-status"' in html
assert "const laterSync = createLaterSync({" in html
assert "onRemoteRecords: records =>" in html
assert "onChange: (action, itemId, wakeAt) =>" in html
assert "onExpire: ids =>" in html
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
assert "laterSync.migrate(laterWork.read());" in html
assert "laterSync.flush();" in html
assert "Later saved to account." in html
assert "Another device changed this Later item" in html
assert "Today edit expired after 30 days offline" in html
assert "Later edit expired after 30 days offline" in html
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v72" in source
assert "BASE + 'static/later-sync.js'" in source
+94
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import httpx
import pytest
from src import main
@@ -21,6 +22,9 @@ def reset_live_snapshot_task():
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
yield
main._live_snapshot_task = None
main._live_snapshot_value = None
@@ -35,6 +39,9 @@ def reset_live_snapshot_task():
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
def payload(response):
@@ -84,6 +91,93 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}
assert set(result["revisions"]) == {"context", "events", "notifications"}
assert len(set(result["revisions"].values())) == 1
assert result["revisions"]["context"].endswith(".1")
@pytest.mark.anyio
async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
async def events(_authenticated_user):
return [{"type": "push"}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", empty)
first = payload(await main.live_snapshot())
second = payload(await main.live_snapshot(
context_revision=first["revisions"]["context"],
events_revision=first["revisions"]["events"],
notifications_revision=first["revisions"]["notifications"],
))
assert "context" not in second
assert "events" not in second
assert "notifications" not in second
assert "notification_pagination" not in second
assert second["sections"] == first["sections"]
assert second["revisions"] == first["revisions"]
assert "freshness" in second
@pytest.mark.anyio
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
monkeypatch.setattr(main, "_live_revision_generation", "worker-a")
worker_a = payload(await main.live_snapshot())
worker_a_token = worker_a["revisions"]["context"]
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_section_created_at = {
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
monkeypatch.setattr(main, "_live_revision_generation", "worker-b")
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
assert worker_a_token == "worker-a.1"
assert worker_b["revisions"]["context"] == "worker-b.1"
assert worker_b["context"]["user"]["login"] == "timmy"
@pytest.mark.anyio
async def test_live_snapshot_rejects_malformed_revision_before_upstream_work(monkeypatch):
async def unexpected_user():
raise AssertionError("malformed revision reached upstream work")
monkeypatch.setattr(main, "current_user", unexpected_user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/live?context_revision=not-a-token")
assert response.status_code == 422
@pytest.mark.anyio
+74
View File
@@ -0,0 +1,74 @@
import sqlite3
from src.login_attempt_store import LoginAttemptStore, client_source
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
trusted = "127.0.0.0/8, 10.0.0.0/8"
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
assert (
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
== "203.0.113.7"
)
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
now = [1_000.0]
database = tmp_path / "login-attempts.sqlite3"
first = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
second = LoginAttemptStore(
database,
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
assert first.retry_after("203.0.113.7") == 0
first.record_failure("203.0.113.7")
second.record_failure("203.0.113.7")
first.record_failure("203.0.113.7")
assert second.retry_after("203.0.113.7") == 60
now[0] = 1_060.0
assert first.retry_after("203.0.113.7") == 0
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
now = [1_000.0]
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: now[0],
max_failures=3,
window_seconds=60,
)
store.record_failure("203.0.113.1")
store.record_failure("203.0.113.2")
now[0] = 1_060.0
store.record_failure("203.0.113.3")
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
store = LoginAttemptStore(
tmp_path / "login-attempts.sqlite3",
clock=lambda: 1_000.0,
max_failures=3,
window_seconds=60,
max_entries=2,
)
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
store.record_failure(address)
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
+298
View File
@@ -0,0 +1,298 @@
import json
import subprocess
from pathlib import Path
import pytest
from src.views import login
ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js"
def test_expired_session_reason_explains_preserved_private_work():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
}});
controller.showReason('session-expired');
const expired = status.textContent;
controller.showReason('https://evil.example/redirect');
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["expired"] == (
"Your session expired. Private drafts remain on this device. "
"Sign in to continue."
)
assert state["ignored"] == state["expired"]
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{cleared:false, snapshots:[]}};
const status = {{textContent:''}};
const button = {{disabled:false}};
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status, button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
location:{{replace:()=>{{}}}},
clearPrivateDeviceData:async()=>{{
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await new Promise(resolve=>setTimeout(resolve,10));
state.cleared=true;
}},
}});
(async()=>{{
const pending=controller.showReason('session-revoked');
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
await pending;
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
process.stdout.write(JSON.stringify(state));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
assert state["final"] == {
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
"disabled": False,
"cleared": True,
}
@pytest.mark.anyio
async def test_login_loads_private_data_purger_before_controller():
html = await login()
assert '<script src="static/private-device-data.js"></script>' in html
assert html.index('static/private-device-data.js') < html.index('static/login.js')
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status={{textContent:''}};
const button={{disabled:false}};
const controller=createLoginController({{
form:{{reset:()=>{{}}}},status,button,
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
}});
(async()=>{{
await controller.showReason('session-revoked');
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
}})();
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"status": "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.",
"disabled": True,
}
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ reset: 0, interval: null }};
const status = {{ textContent: '' }};
const button = {{ disabled: false }};
const form = {{ reset: () => state.reset += 1 }};
const controller = createLoginController({{
form, status, button,
fetchImpl: async () => new Response(JSON.stringify({{ detail: 'Too many sign-in attempts' }}), {{ status: 429, headers: {{ 'Retry-After': '2' }} }}),
location: {{ replace: () => {{}} }},
setIntervalImpl: callback => {{ state.interval = callback; return 1; }},
clearIntervalImpl: () => {{}},
}});
(async () => {{
await controller.submit('never-store-this-token');
state.initial = {{ disabled: button.disabled, status: status.textContent, reset: state.reset }};
state.interval();
state.afterTick = {{ disabled: button.disabled, status: status.textContent }};
state.interval();
state.finished = {{ disabled: button.disabled, status: status.textContent }};
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["initial"] == {
"disabled": True,
"status": "Too many attempts. Try again in 2 seconds.",
"reset": 1,
}
assert state["afterTick"]["disabled"] is True
assert state["finished"] == {
"disabled": False,
"status": "You can try signing in again.",
}
def test_successful_login_resumes_valid_shared_capture_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ replaced: null }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => state.replaced = value }},
continuation: './?title=Production+crash&url=https%3A%2F%2Fexample.com',
}});
(async () => {{
await controller.submit('operator-token');
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["replaced"] == (
"./?title=Production+crash&url=https%3A%2F%2Fexample.com"
)
def test_login_sends_a_bounded_device_label_with_the_access_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
let request = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async (url, options) => {{ request = {{url, body: JSON.parse(options.body)}}; return new Response('{{}}', {{ status: 200 }}); }},
location: {{ replace: () => {{}} }},
}});
(async () => {{
await controller.submit('operator-token', 'Timmy’s Pixel');
process.stdout.write(JSON.stringify(request));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"url": "api/v1/session",
"body": {"access_token": "operator-token", "device_label": "Timmy’s Pixel"},
}
def test_shared_capture_login_explains_why_sign_in_is_required():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }}, continuation: './?title=Shared',
}});
process.stdout.write(status.textContent);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert result.stdout == "Sign in to continue your shared capture."
def test_login_rejects_untrusted_or_non_share_continuations():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => {{
process.stdout.write(JSON.stringify({{
crossOrigin: await destination('https://evil.example/steal'),
otherRoute: await destination('./settings?title=Shared'),
extraField: await destination('./?title=Shared&next=https%3A%2F%2Fevil.example'),
prototypeField: await destination('./?toString=Shared'),
oversized: await destination('./?title=' + 'x'.repeat(201)),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"crossOrigin": "./",
"otherRoute": "./",
"extraField": "./",
"prototypeField": "./",
"oversized": "./",
}
def test_login_bootstrap_continues_shared_capture_after_submit():
harness = f"""
const fs = require('fs');
const vm = require('vm');
const state = {{ replaced: null, submit: null }};
const elements = {{
'sign-in': {{ reset: () => {{}}, addEventListener: (_name, handler) => state.submit = handler }},
'status': {{ textContent: '' }},
'submit-sign-in': {{ disabled: false }},
}};
const context = {{
URLSearchParams, Response, setInterval, clearInterval,
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
fetch: async () => new Response('{{}}', {{ status: 200 }}),
document: {{ getElementById: id => elements[id] }},
window: {{ location: {{
search: '?continue=.%2F%3Ftitle%3DShared%2Blink%26url%3Dhttps%253A%252F%252Fexample.com',
replace: value => state.replaced = value,
}} }},
}};
context.fetch.bind = Function.prototype.bind;
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(LOGIN_JS))}, 'utf8'), context);
state.submit({{ preventDefault: () => {{}} }});
setTimeout(() => process.stdout.write(JSON.stringify({{
replaced: state.replaced, status: elements.status.textContent,
}})), 0);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"replaced": "./?title=Shared+link&url=https%3A%2F%2Fexample.com",
"status": "Signing in…",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html
assert '<p id="status" role="status" aria-live="polite"></p>' in html
assert 'name="device_label"' in html
assert 'maxlength="64"' in html
+88
View File
@@ -1,4 +1,5 @@
import json
import re
import subprocess
from html.parser import HTMLParser
from pathlib import Path
@@ -9,6 +10,19 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
RENDERER = FRONTEND / "markdown.js"
def render_markdown(payload):
script = (
f"const render = require({json.dumps(str(RENDERER))});"
f"process.stdout.write(render({json.dumps(payload)}));"
)
return subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
).stdout
class ScriptSourceParser(HTMLParser):
def __init__(self):
super().__init__()
@@ -50,3 +64,77 @@ def test_markdown_script_resolves_inside_dashboard_subpath():
assert urljoin(
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
def test_markdown_renderer_preserves_mobile_reading_structure():
rendered = render_markdown(
"## Plan\n\n> Ship this\n\n- [x] Tested\n- [ ] Released\n\n"
"```python\nprint('ready')\n```"
)
assert rendered == (
"<h2>Plan</h2><blockquote><p>Ship this</p></blockquote>"
'<ul class="task-list"><li class="task-list-item">'
'<input type="checkbox" disabled checked> Tested</li>'
'<li class="task-list-item"><input type="checkbox" disabled> Released</li></ul>'
'<pre><code class="language-python">print(&#39;ready&#39;)</code></pre>'
)
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
rendered = render_markdown(
"[Forge](https://forge.example/work?q=1&safe=yes) "
"[relative](/git/org/repo/issues/1) "
"[attack](javascript:alert(1)) <img src=x onerror=alert(2)>"
)
assert (
'<a href="https://forge.example/work?q=1&amp;safe=yes" target="_blank" '
'rel="noopener noreferrer">Forge</a>' in rendered
)
assert (
'<a href="/git/org/repo/issues/1" target="_blank" '
'rel="noopener noreferrer">relative</a>' in rendered
)
assert "javascript:" not in rendered
assert "<img" not in rendered
assert "&lt;img src=x onerror=alert(2)&gt;" in rendered
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
dashboard = (FRONTEND / "dashboard.js").read_text()
expected_paths = (
"renderMarkdown(comment.body || 'No comment body provided.')",
"renderMarkdown(detail.subject_body || 'No subject context was provided.')",
"renderMarkdown(detail.body || 'No description provided.')",
"renderMarkdown(item.body || 'No description provided.')",
"renderMarkdown(review.body)",
"renderMarkdown(confirmed.body || 'No description provided.')",
)
for path in expected_paths:
assert path in dashboard
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
def test_markdown_work_bodies_are_mobile_safe_block_containers():
html = (FRONTEND / "index.html").read_text()
css = (FRONTEND / "dashboard.css").read_text()
worker = (FRONTEND / "service-worker.js").read_text()
for body_id in (
"issue-sheet-body",
"pull-sheet-body",
"review-sheet-body",
"update-subject-body",
"search-preview-body",
):
body_tag = re.search(rf'<div\b[^>]*\bid="{body_id}"[^>]*>', html)
assert body_tag
assert "markdown-content" in body_tag.group(0)
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v72" in worker
+38
View File
@@ -0,0 +1,38 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
HTML = ROOT / "frontend" / "index.html"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
CSS = ROOT / "frontend" / "dashboard.css"
WORKER = ROOT / "frontend" / "service-worker.js"
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/mobile-composer-viewport.js"></script>' in html
assert "createMobileComposerViewport({" in dashboard
for panel, workspace, composer, submit, status in [
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
]:
for selector in (panel, workspace, composer, submit, status):
assert repr(selector) in dashboard
assert "height:var(--composer-viewport-height,100dvh)" in css
assert "top:var(--composer-viewport-top,0px)" in css
assert ".composer-keyboard-active" in css
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
html = HTML.read_text()
worker = WORKER.read_text()
local_assets = set(re.findall(r'(?:src|href)="((?:static/|manifest\.webmanifest)[^"?#]*)', html))
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v72" in worker
+129
View File
@@ -0,0 +1,129 @@
import json
import subprocess
from pathlib import Path
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
def run_controller_scenario(scenario: str) -> dict:
harness = f"""
const createController = require({json.dumps(str(CONTROLLER))});
class Target {{
constructor() {{ this.listeners = {{}}; }}
addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }}
removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }}
dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }}
count(name) {{ return this.listeners[name]?.size || 0; }}
}}
function element() {{
const target = new Target();
target.style = {{
values: {{}},
setProperty(name, value) {{ this.values[name] = value; }},
removeProperty(name) {{ delete this.values[name]; }},
}};
target.scrollTop = 0;
target.reveals = [];
target.scrollIntoView = options => target.reveals.push(options);
target.contains = candidate => candidate === target || candidate?.workspace === target;
return target;
}}
const viewport = new Target();
viewport.offsetTop = 12;
viewport.height = 360;
const mediaQuery = {{matches:true}};
const panel = element();
const workspace = element();
const composer = element(); composer.workspace = workspace;
const submit = element(); submit.workspace = workspace;
const status = element(); status.workspace = workspace;
const document = {{activeElement:null}};
const scheduled = [];
const controller = createController({{
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
schedule: callback => scheduled.push(callback),
}});
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
{scenario}
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls():
result = run_controller_scenario(
"""
controller.start();
panel.scrollTop = 240;
document.activeElement = composer;
composer.dispatch('focus');
viewport.offsetTop = 20;
viewport.height = 300;
viewport.dispatch('resize');
flush();
process.stdout.write(JSON.stringify({
geometry: panel.style.values,
resizeListeners: viewport.count('resize'),
scrollListeners: viewport.count('scroll'),
revealCount: workspace.reveals.length,
revealOptions: workspace.reveals.at(-1),
}));
"""
)
assert result == {
"geometry": {
"--composer-viewport-top": "20px",
"--composer-viewport-height": "300px",
},
"resizeListeners": 1,
"scrollListeners": 1,
"revealCount": 2,
"revealOptions": {"block": "end", "inline": "nearest"},
}
def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners():
result = run_controller_scenario(
"""
controller.start();
controller.start();
panel.scrollTop = 240;
document.activeElement = composer;
composer.dispatch('focus');
composer.dispatch('focus');
document.activeElement = null;
workspace.dispatch('focusout');
flush();
const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')};
panel.scrollTop = 99;
document.activeElement = composer;
composer.dispatch('focus');
controller.close(panel);
flush();
process.stdout.write(JSON.stringify({
afterBlur,
afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')},
}));
"""
)
assert result == {
"afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0},
"afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0},
}
def test_desktop_and_missing_visual_viewport_are_noop():
result = run_controller_scenario(
"""
mediaQuery.matches = false;
controller.start();
document.activeElement = composer;
composer.dispatch('focus');
process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length}));
"""
)
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
+72
View File
@@ -0,0 +1,72 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
script = f"""
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
process.stdout.write(JSON.stringify({{
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"today": "today",
"attention": "attention",
"all": "all",
"saved": "review",
}
def test_mobile_disclosure_closes_on_escape_and_restores_launcher_focus():
script = f"""
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
const listeners = {{}};
const disclosure = {{open:true, addEventListener(name, fn) {{ listeners[name] = fn; }}}};
const launcher = {{focuses:0, focus() {{ this.focuses += 1; }}}};
const controller = mobileLaunch.createDisclosure({{disclosure, launcher}});
controller.start();
let prevented = false;
listeners.keydown({{key:'Escape', preventDefault() {{ prevented = true; }}}});
process.stdout.write(JSON.stringify({{open:disclosure.open, focuses:launcher.focuses, prevented}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"open": False, "focuses": 1, "prevented": True}
@pytest.mark.anyio
async def test_mobile_launch_progressively_discloses_secondary_controls():
html = await dashboard()
assert 'class="app-brand"' in html
assert 'class="app-live-status"' in html
assert 'class="app-menu"' in html
assert 'id="app-menu-toggle"' in html
assert 'class="work-settings"' in html
assert 'id="work-settings-toggle"' in html
assert 'id="active-work-queue"' in html
assert '<script src="static/mobile-launch.js"></script>' in html
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
assert "BASE + 'static/mobile-launch.js'" in service_worker
assert "mobileLaunch.chooseFilter" in html
assert "mobileLaunch.createDisclosure" in html
assert "@media (max-width: 600px)" in html
assert "header { min-height:56px; max-height:64px;" in html
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
assert ".my-work-actions button { min-height:44px;" in html
+92 -4
View File
@@ -4,10 +4,43 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const state = {{active:true, resumable:true, today:2}};
const calls = [];
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => state.resumable,
getTodayCount: () => state.today,
continueToday: () => calls.push('continue'),
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
openFallback: () => calls.push('fallback'),
}});
const modes = [];
modes.push(entry.open());
state.active = false; modes.push(entry.open());
state.resumable = false; modes.push(entry.open());
state.today = 0; modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "resume", "start", "work"],
"calls": ["continue", "resume", "start", "fallback"],
}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
@@ -70,6 +103,43 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const workLabel = {{ textContent:'' }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
dock.updateWork('continue', 3);
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateWork('resume', 0);
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateWork('start', 0);
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('work', 0);
process.stdout.write(JSON.stringify({{
continuing, resuming, starting,
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"continuing": {
"text": "Continue",
"count": "3",
"hidden": False,
"label": "Continue Today, 3 items need attention",
},
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"fallback": {"text": "Work", "label": "Work"},
}
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -95,6 +165,24 @@ process.stdout.write(JSON.stringify({{
}
@pytest.mark.anyio
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
html = await dashboard()
assert 'id="mobile-work-label">Work</span>' in html
assert '<script src="static/mobile-work-entry.js"></script>' in html
assert "const mobileWorkEntry = createMobileWorkEntry({" in html
assert "isTodayActive: () => workSession.checkpointed()" in html
assert "isTodayResumable: () => workSession.resumable()" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "continueToday: continueTodaySession" in html
assert "resumeToday: resumeTodaySession" in html
assert "startToday: startTodaySession" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
assert "workSession.reopen()" in html
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
@@ -112,12 +200,12 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: openMobileWork" in html
assert "counts.attention ? 'attention' : 'all'" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "const filter = counts.attention ? 'attention' : 'all';" in html
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
assert "draftCount.textContent = sourceDraftCount.textContent" in html
assert "mobileTaskDock.updateAttention(counts.attention)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
+990 -10
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more