Compare commits

...
Author SHA1 Message Date
timmy 8f7a836d28 feat: batch queued planning edits (#375)
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
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 / 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 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 / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
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 / 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 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 / 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 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 / 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 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 / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 40s
CI / build-release (pull_request) Successful in 5s
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 / 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 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 / lint (pull_request) Successful in 37s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 33s
CI / build-release (pull_request) Successful in 5s
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 / lint (push) Successful in 35s
CI / build-frontend (push) Successful in 4s
2026-08-08 20:18:39 +00:00
timmy b4dc785dd8 security: purge data after remote session revocation (#337)
CI / lint (pull_request) Successful in 37s
CI / build-frontend (pull_request) Successful in 6s
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 / lint (push) Successful in 34s
CI / build-frontend (push) Successful in 5s
2026-08-08 19:55:48 +00:00
timmy 4969cadd82 feat: serialize autonomous release runs (#335)
CI / lint (pull_request) Successful in 35s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 30s
CI / build-frontend (push) Successful in 4s
2026-08-08 19:36:00 +00:00
timmy 7342b2e6de security: isolate release subprocess credentials (#333)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 34s
CI / build-frontend (push) Successful in 5s
2026-08-08 19:19:42 +00:00
timmy 503d6f4988 feat: resume release runs from checkpoints (#331)
CI / lint (pull_request) Successful in 33s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 19:18:27 +00:00
rockachopa eb2c84a6c2 Bound autonomous release commands with process-tree deadlines (#330)
CI / lint (push) Successful in 32s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #329
2026-08-08 18:55:29 +00:00
timmy fb6b42bcaf feat: bound autonomous release command runtime (#329)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 33s
CI / build-frontend (push) Successful in 4s
Closes #327
2026-08-08 18:36:24 +00:00
timmy c9c5939f0a security: bound and redact inbound requests (#327)
CI / lint (pull_request) Successful in 33s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 34s
CI / build-frontend (push) Successful in 4s
2026-08-08 18:01:26 +00:00
timmy 7034ec55ed feat: review and revoke active devices (#325)
CI / lint (pull_request) Successful in 34s
CI / build-frontend (pull_request) Successful in 6s
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 / lint (push) Successful in 33s
CI / build-frontend (push) Successful in 4s
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
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 31s
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
CI / lint (push) Successful in 32s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 16:41:43 +00:00
timmy dc18004e02 fix: recover orphaned authored operations (#319)
CI / lint (pull_request) Successful in 32s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 34s
CI / build-frontend (push) Successful in 5s
2026-08-08 16:16:00 +00:00
timmy b5b6b4d0a9 feat: make mobile work search keyboard-safe (#317)
CI / lint (pull_request) Successful in 34s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 28s
CI / build-frontend (push) Successful in 4s
2026-08-08 15:56:38 +00:00
timmy 9a152c435e feat: make mobile task overlays back-safe (#315)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 31s
CI / build-frontend (push) Successful in 5s
2026-08-08 15:20:01 +00:00
timmy 8ba4d48549 feat: preserve mobile shares through sign-in (#313)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 32s
CI / build-frontend (push) Successful in 4s
Closes #311
2026-08-08 14:57:59 +00:00
timmy d026bbd400 perf: isolate authentication database work (#311)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 30s
CI / build-frontend (push) Successful in 5s
2026-08-08 14:33:46 +00:00
timmy 9786724678 fix: recover expired dashboard sessions (#309)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 30s
CI / build-frontend (push) Successful in 5s
2026-08-08 14:15:08 +00:00
timmy 7d633e67e8 security: isolate release issue content from shell execution (#307)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 29s
CI / build-frontend (push) Successful in 5s
2026-08-08 13:48:06 +00:00
timmy add45365ab fix: recover stalled background delivery (#305)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 29s
CI / build-frontend (push) Successful in 4s
Closes #303
2026-08-08 13:16:28 +00:00
timmy 40c66df143 fix: recover live refresh from stalled requests (#303)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 29s
CI / build-frontend (push) Successful in 5s
2026-08-08 12:40:24 +00:00
timmy 9f6476a5b4 feat: capture cold-offline work for filing (#301)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
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
CI / lint (push) Successful in 33s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 12:19:58 +00:00
timmy ccf5f69400 feat: show priority work above fold on mobile (#299)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 12:18:24 +00:00
timmy 207df76b69 Merge pull request 'Drain closed-app queued work concurrently without failure starvation' (#298) from timmy/297-bounded-background-drain into main
Release / release-candidate (push) Successful in 4s
CI / lint (push) Successful in 27s
CI / build-frontend (push) Successful in 4s
2026-08-08 11:57:09 +00:00
timmy 43c7a85eec perf: drain background outbox concurrently (#297)
CI / lint (pull_request) Successful in 27s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 27s
CI / build-frontend (push) Successful in 6s
Closes #295
2026-08-08 11:39:23 +00:00
timmy 6e6e63e553 security: enforce strict browser execution boundary (#295)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 27s
CI / build-frontend (push) Successful in 4s
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
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
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 / lint (push) Successful in 28s
CI / build-frontend (push) Successful in 5s
2026-08-08 10:22:06 +00:00
timmy 8c4e729c4a fix: keep retained mobile planning available (#289)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 28s
CI / build-frontend (push) Successful in 4s
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 / lint (push) Successful in 26s
CI / build-frontend (push) Successful in 4s
2026-08-08 09:37:09 +00:00
timmy fedddb246d perf: skip unchanged live snapshot sections (#285)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 09:35:48 +00:00
timmy e28472b268 Retry edited offline issue captures with a fresh idempotency key (#284)
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
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 / lint (push) Successful in 28s
CI / build-frontend (push) Successful in 5s
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
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 27s
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 / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 27s
CI / build-frontend (push) Successful in 4s
2026-08-08 07:34:43 +00:00
timmy e58808f2d4 fix: reconcile ambiguous merge outcomes (#277)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 07:33:38 +00:00
timmy e0543b2007 Keep operator session validation off the event loop (#276)
CI / lint (push) Successful in 26s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
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 / lint (push) Successful in 26s
CI / build-frontend (push) Successful in 5s
2026-08-08 06:55:34 +00:00
timmy 98d6a29fa0 feat: surface deadline-critical work in Attention (#273)
CI / lint (pull_request) Successful in 27s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 29s
CI / build-frontend (push) Successful in 5s
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)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 26s
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 / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 7s
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 / lint (push) Successful in 25s
CI / build-frontend (push) Successful in 4s
Closes #268
2026-08-08 05:57:06 +00:00
timmy c8de28ca0c feat: revoke operator sessions on logout (#268)
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 5s
2026-08-08 05:34:02 +00:00
timmy acd2d82d10 fix: resume queued delivery after sign-in (#266)
CI / lint (pull_request) Successful in 24s
CI / build-frontend (pull_request) Successful in 4s
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 / lint (push) Successful in 25s
CI / build-frontend (push) Successful in 4s
Closes #264
2026-08-08 05:11:20 +00:00
timmy c74c63d29c feat: make background queue admission durable (#264)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 5s
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 / lint (push) Successful in 25s
CI / build-frontend (push) Successful in 4s
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 / lint (push) Successful in 30s
CI / build-frontend (push) Successful in 4s
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 / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:42:53 +00:00
timmy 39357263d0 feat: require operator sessions for privileged access (#258)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:41:30 +00:00
rockachopa e40c75b360 Merge pull request 'Defer from mobile detail sheets and continue triage' (#257) from timmy/256-mobile-detail-defer into main
CI / lint (push) Successful in 24s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 03:17:03 +00:00
timmy fdb39e43ad feat: defer from mobile detail sheets (#256)
CI / lint (pull_request) Successful in 22s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 03:16:01 +00:00
rockachopa 61168dbfd4 Defer mobile Attention items into a Later queue (#255)
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
Closes #254
2026-08-08 02:42:22 +00:00
timmy 65a09b5c66 feat: add mobile Later queue (#254)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 02:41:09 +00:00
rockachopa 8e0389153c Merge pull request 'Deliver queued authored messages after the app closes' (#253) from timmy/252-background-authored-message-sync into main
Release / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 4s
2026-08-08 02:12:33 +00:00
timmy c3e8ead47b feat: deliver authored messages after app close (#252)
CI / lint (pull_request) Successful in 23s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 02:11:26 +00:00
timmy 5f6dd265a5 Merge pull request 'Deliver queued issue captures after the app closes' (#251) from timmy/250-background-issue-sync into main
Release / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 24s
CI / build-frontend (push) Successful in 6s
2026-08-08 01:45:29 +00:00
107 changed files with 18038 additions and 3399 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
+225 -21
View File
@@ -21,14 +21,47 @@ 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. 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. 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. 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
@@ -44,19 +77,103 @@ head-scoped draft comments anchored to changed lines; the dashboard validates ea
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
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. Then start the API and bundled frontend:
Serve the dashboard only to trusted users on its own origin; cross-origin API
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
```
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
Gitea connection directly, request
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
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
and stylesheet are same-origin static assets included 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. The signing key is never derived from the access token.
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,
@@ -84,6 +201,12 @@ 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`
revisions. The browser sends its known revisions 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.
## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
@@ -92,6 +215,18 @@ 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 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 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; `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
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
@@ -103,19 +238,48 @@ 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. Review
submission is never queued: reconnect reloads the current head before enabling submit;
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
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 are also mirrored into IndexedDB and registered with Background Sync, so a
supporting installed browser can deliver them 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
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
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
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.
@@ -162,6 +326,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'
```
@@ -170,6 +338,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.
+75 -15
View File
@@ -1,4 +1,4 @@
function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
const storageKey = 'stackchain.authored-outbox.v1';
const makeId = createOperationId || (() =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
@@ -14,12 +14,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
} catch (_error) { return []; }
}
function write(items) {
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
coordinator?.notify('authored');
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.');
@@ -42,10 +47,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
queuedAt: Number(now()),
};
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 => {
@@ -58,6 +77,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
status: 'queued',
};
delete updated.error;
delete updated.deliveryState;
return updated;
}));
return updated;
@@ -84,15 +104,27 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
if (pending.has(item.id)) return pending.get(item.id);
const request = (async () => {
try {
const 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 }),
});
let result;
if (backgroundSync?.send) {
const delivery = await backgroundSync.send(item, currentLogin);
if (delivery.attention) {
const error = delivery.error || new Error('Message needs attention');
error.status = Number(error.status || 422);
throw error;
}
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 (!result) return { blocked: true };
discard(item.id);
return { result };
} catch (error) {
@@ -103,6 +135,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
...candidate,
status: 'attention',
error: String(error.message || 'Message needs attention').slice(0, 240),
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
} : candidate));
}
return { error, transient: !permanent };
@@ -139,7 +172,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
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 };
}
@@ -149,7 +192,24 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, getOwnerLogin =
return retryItem(id, currentLogin);
}
return { enqueue, update, discard, flush, retry, list: () => read().map(item => ({ ...item })) };
function reconcileBackground(records) {
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 === 'attention') return [{
...item,
status: 'attention',
error: String(background.error || 'Message needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
});
write(items);
return items;
}
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
+236 -24
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,16 +45,26 @@ 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 } = {}) {
const transact = transaction || createIndexedDbTransaction(indexedDB);
async function reconcile(items) {
async function reconcile(items, outboxLane = 'issue') {
return transact(async records => {
const existing = await records.getAll();
const incoming = new Map(items.map(item => [item.id, { ...item }]));
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);
if (!replacement) {
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
@@ -79,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());
@@ -110,23 +153,117 @@ 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,
claimBatch,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
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 issueRequest(item) {
function createBackgroundIssueSync({
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
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 === '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-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
item,
);
}
return {
url: base + 'api/v1/repos/' + repository + '/issues',
options: {
@@ -147,17 +284,41 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
};
}
function authoredRequest(url, item) {
return {
url: base + url,
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
},
};
}
async function deliver(item) {
const request = issueRequest(item);
const request = deliveryRequest(item);
try {
const issue = await fetchJson(request.url, request.options);
const delivered = await requestJson(request.url, request.options);
await store.complete(item.id);
return { issue };
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;
@@ -165,6 +326,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);
@@ -172,29 +334,79 @@ 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;
}
async function purge() {
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
}
return {
flush, send,
reconcile: items => store.reconcile(items),
flush, send, purge,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
};
}
+66 -11
View File
@@ -5,11 +5,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 +30,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,6 +114,11 @@ function createContextPoller({
stop() {
stopped = true;
cancelTimer();
if (activeRequest) {
const request = activeRequest;
activeRequest = null;
supersede(request);
}
},
};
}
+50 -1
View File
@@ -23,6 +23,9 @@ 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 safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@@ -140,6 +143,51 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
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 +214,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
}
+362
View File
@@ -0,0 +1,362 @@
: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; }
.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 button { min-height:44px; max-width:100%; }
.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%; }
.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%; }
.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; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 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-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-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-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; }
.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
+31
View File
@@ -0,0 +1,31 @@
function createDetailDefer({
laterWork,
session,
close,
refresh,
focus,
announce,
formatTime = value => new Date(value).toLocaleString(),
}) {
return {
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
if (!item) 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 && closeSheet) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
if (!inSession && restoreFocus) focus();
return true;
},
defer(item, preset) {
return this.deferUntil(item, laterWork.presetUntil(preset));
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
+6 -2
View File
@@ -113,7 +113,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
outbox_id: item.id,
kind: 'issue-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
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(' — ')),
@@ -144,7 +146,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
outbox_id: item.id,
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' ? 'Needs attention' : 'Queued message'),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
+128 -2979
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};
});
+67 -12
View File
@@ -13,17 +13,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
} catch (_error) { return []; }
}
function write(items) {
function write(items, mirror = true) {
storage?.setItem(storageKey, JSON.stringify({ version: 2, 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();
@@ -45,27 +45,70 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
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 (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;
@@ -110,6 +153,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
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) };
@@ -146,8 +190,18 @@ 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);
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] : [], remaining: read(), blocked: 0 };
}
@@ -163,6 +217,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
if (background?.status === 'sent') 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 +225,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return items;
}
return { enqueue, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, 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;
+247
View File
@@ -0,0 +1,247 @@
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
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;
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)
.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();
records.forEach(record => latestByItem.set(record.operation.item_id, record));
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
storage.removeItem(record.recordKey);
knownOperationKeys.delete(record.recordKey);
});
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
.map(record => record.operation).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 };
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: Date.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();
try {
let operations = pending();
let plan;
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);
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' : 'saved');
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;
+133
View File
@@ -0,0 +1,133 @@
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
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 value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (_error) {
return {};
}
}
function write(records) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function defer(item, until) {
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
const wakeAt = wake.toISOString();
records[id] = wakeAt;
if (!write(records)) return 'unavailable';
onChange('defer', id, wakeAt);
return 'deferred';
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
if (preset === 'tomorrow') {
const wake = new Date(current);
wake.setDate(wake.getDate() + 1);
wake.setHours(9, 0, 0, 0);
return wake;
}
return new Date(NaN);
}
function restore(item) {
const id = identity(item);
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
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;
if (!wakeTimes.length) return;
const delay = Math.max(0, Math.min(...wakeTimes) - current);
timer = setTimer(() => {
timer = null;
onWake();
}, Math.min(delay, 2147483647));
}
function partition(items, { pruneMissing = true } = {}) {
const current = now().getTime();
const records = read();
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) {
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) && expired.length) {
onExpire(expired);
}
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
const later = (items || []).flatMap(item => {
const wake = retained[identity(item)];
return wake ? [{ ...item, deferred_until: wake }] : [];
});
return { active, later };
}
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; });
},
};
};
});
+57 -18
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);
@@ -560,7 +599,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,
+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;
+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) {
+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;
+154 -9
View File
@@ -1,22 +1,36 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v12';
const CACHE = 'stackchain-dashboard-shell-v54';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.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',
@@ -24,26 +38,125 @@ const SHELL = [
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-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 fetchJson(url, options) {
const response = await fetch(new URL(url, self.location.origin), options);
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 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 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()));
});
@@ -56,7 +169,33 @@ 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(flushAndNotify());
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 => {
@@ -66,9 +205,11 @@ 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);
@@ -77,7 +218,11 @@ self.addEventListener('fetch', event => {
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
return cache.match(BASE);
const cached = await cache.match(BASE);
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;
+292
View File
@@ -0,0 +1,292 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else {
const base = new URL('./', root.location.href).pathname;
const originalFetch = root.fetch.bind(root);
const boundary = factory({
cookie: () => root.document.cookie,
origin: root.location.origin,
base,
fetchImpl: originalFetch,
localStorage: root.localStorage,
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.resumeQueuedWork();
};
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
else attach();
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
}) {
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 handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
}
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
.map(value => value.trim())
.find(value => value.startsWith('stackchain_csrf='));
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
}
function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; }
}
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)) {
const headers = new Headers(options.headers || input?.headers || {});
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
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 fetchImpl(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
action: detail.action,
target: detail.target,
}),
});
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 {
onExpired();
location.replace(base + 'login?reason=session-expired');
}
}
return response;
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
try {
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));
} 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 {
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' });
} finally {
await clearPrivateDeviceData();
location.assign(base + 'login');
}
}
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,
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']);
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; },
};
};
});
+235
View File
@@ -0,0 +1,235 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
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;
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);
}
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
.filter(record => record?.operation)
.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)
.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 };
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: Date.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();
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' : 'saved');
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()
+222
View File
@@ -0,0 +1,222 @@
"""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
import time
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 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 configuration_error() is not None or mode() != OPERATOR_MODE:
raise RuntimeError("Dashboard authentication is not configured")
return configured.encode()
def _encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
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(
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, "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_with_reason(
value: str | None, now: int | None = None
) -> SessionVerification:
if not value or "." not in value or not enabled():
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 SessionVerification(None)
try:
payload = json.loads(_decode(encoded))
session = Session(
session_id=payload["sid"],
csrf=payload["csrf"],
expires_at=int(payload["exp"]),
)
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
return SessionVerification(None)
current = int(time.time() if now is None else now)
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 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:
root_path = request.scope.get("root_path", "").rstrip("/")
return root_path or "/"
def application_path(request: Request) -> str:
path = request.url.path
root_path = request.scope.get("root_path", "").rstrip("/")
if root_path and (path == root_path or path.startswith(root_path + "/")):
return path[len(root_path):] or "/"
return path
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
+115
View File
@@ -680,6 +680,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 +725,7 @@ async def notification_detail(thread_id: int) -> dict:
else "",
"url": latest_url,
},
"issue": issue,
"conversation": conversation,
}
@@ -1038,6 +1054,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 +1570,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(
+190
View File
@@ -0,0 +1,190 @@
"""Durable, account-scoped Later deferrals."""
import json
import sqlite3
from datetime import datetime
from pathlib import Path
class LaterStore:
def __init__(self, path: str | Path, *, timeout: float = 1.0):
self.path = Path(path)
self.timeout = timeout
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,
PRIMARY KEY (login, operation_id)
)
"""
)
return connection
@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,
) -> 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 {"defer", "restore"}:
raise ValueError("unsupported Later action")
if action == "defer":
wake_at = self._validate_wake_at(wake_at)
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)
duplicate = connection.execute(
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
(login, operation_id),
).fetchone()
if duplicate:
return snapshot
records = dict(snapshot["records"])
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 = snapshot["revision"] + (1 if changed else 0)
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 changed:
connection.execute(
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
(revision, serialized, login),
)
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(login, operation_id),
)
return {"revision": revision, "records": 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] = []
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
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
connection.execute(
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
(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": [],
}
+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
+832 -26
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
+211
View File
@@ -0,0 +1,211 @@
"""Durable, account-scoped ordered Today plans."""
import json
import sqlite3
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):
self.path = Path(path)
self.limit = limit
self.timeout = timeout
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,
PRIMARY KEY (login, operation_id)
)
"""
)
return connection
@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),
)
connection.execute(
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
(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
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
connection.execute(
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
(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,
}
+13
View File
@@ -8,6 +8,14 @@ DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.ht
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>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):
def __init__(self, content, status_code=200, headers=None, media_type=None, background=None):
@@ -21,6 +29,11 @@ async def dashboard() -> str:
return DASHBOARD_FILE.read_text()
@router.get("/login", response_class=RevalidatingHTMLResponse)
async def login() -> str:
return LOGIN_HTML
@router.get("/manifest.webmanifest", response_class=FileResponse)
async def web_app_manifest() -> FileResponse:
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from src.views import dashboard as dashboard_html
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
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 assets.
html = await dashboard_html()
return "\n".join(
(
html,
(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
+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):
+138 -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"
@@ -113,6 +113,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 +183,107 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
assert len(output["items"]) == 1
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const mirrors=[]; let requested=0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const backgroundSync = {{
reconcile: async (items, lane) => mirrors.push({{items:items.map(item=>({{...item}})),lane}}),
requestSync: async () => {{requested += 1;}},
}};
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync}});
const sent=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Sent',operationId:'sent'}});
const failed=outbox.enqueue({{kind:'update-reply',notificationId:2,body:'Fix me',operationId:'failed'}});
setTimeout(() => {{
const reconciled=outbox.reconcileBackground([
{{...sent,status:'sent'}},
{{...failed,status:'attention',error:'Reply rejected'}},
]);
setTimeout(() => process.stdout.write(JSON.stringify({{mirrors,requested,reconciled}})), 0);
}}, 0);
"""
output = run_node(script)
assert output["mirrors"][0]["lane"] == "authored"
assert [item["operationId"] for item in output["mirrors"][-1]["items"]] == ["failed"]
assert output["requested"] >= 1
assert [(item["operationId"], item["status"]) for item in output["reconciled"]] == [
("failed", "attention")
]
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))});
const values = new Map(); let backgroundCalls=0; let directCalls=0;
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const backgroundSync = {{
reconcile: async()=>{{}}, requestSync:async()=>{{}},
send: async (item, owner) => {{backgroundCalls += 1; return {{message:{{id:3,owner}}}};}},
}};
const outbox=createAuthoredOutbox({{
storage,getOwnerLogin:()=>'timmy',backgroundSync,
fetchJson:async()=>{{directCalls += 1; return {{id:4}};}},
}});
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Review',operationId:'once'}});
(async()=>{{
const result=await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{backgroundCalls,directCalls,result,remaining:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["backgroundCalls"] == 1
assert output["directCalls"] == 0
assert output["result"]["confirmed"] == [{"id": 3, "owner": "timmy"}]
assert output["remaining"] == []
@pytest.mark.anyio
async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
html = await dashboard()
@@ -160,7 +292,11 @@ 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
assert "backgroundSync: backgroundIssueSync" in html
assert "authoredOutbox.reconcileBackground(records)" in html
+482 -3
View File
@@ -6,7 +6,7 @@ import httpx
import pytest
from src import main
from src.views import dashboard
from tests.dashboard_bundle import dashboard
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
@@ -64,6 +64,210 @@ const fetchJson = async (url, options = {{}}) => {{
}
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"),
[
(
{"kind": "issue-comment", "repository": "stackchain/api", "number": 7},
"api/v1/repos/stackchain/api/issues/7/comments",
),
(
{"kind": "pull-comment", "repository": "stackchain/web", "number": 8},
"api/v1/repos/stackchain/web/pulls/8/comments",
),
(
{"kind": "update-reply", "notificationId": 9},
"api/v1/notifications/9/reply",
),
],
)
def test_closed_app_sync_delivers_each_authored_message_kind(item, expected_url):
authored = {
"id": "message-op",
"operationId": "message-op",
"ownerLogin": "timmy",
"status": "queued",
"body": "Ship this reply",
**item,
}
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}});
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
return {{id:42}};
}};
(async () => {{
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
process.stdout.write(JSON.stringify({{calls,result}}));
}})();
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == expected_url
assert mutation["options"]["headers"]["Idempotency-Key"] == "message-op"
assert json.loads(mutation["options"]["body"]) == {"body": "Ship this reply"}
assert output["result"]["confirmed"] == [{"id": 42}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.reconcile([{{id:'issue',ownerLogin:'timmy',status:'queued'}}], 'issue');
await store.reconcile([{{id:'message',kind:'issue-comment',ownerLogin:'timmy',status:'queued'}}], 'authored');
await store.reconcile([], 'issue');
process.stdout.write(JSON.stringify(await store.snapshot()));
}})();
"""
output = run_node(script)
assert output == [
{"id": "message", "kind": "issue-comment", "ownerLogin": "timmy", "status": "queued", "outboxLane": "authored"}
]
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))});
@@ -89,6 +293,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))});
@@ -117,6 +359,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))});
@@ -146,6 +585,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))});
@@ -255,7 +719,7 @@ const transaction=work=>{{const run=tail.then(()=>work({{
assert output["replay"] is None
assert output["snapshot"] == [
{"id": "done", "ownerLogin": "timmy", "status": "sent", "claimUntil": 0}
{"id": "done", "ownerLogin": "timmy", "status": "sent", "outboxLane": "issue", "claimUntil": 0}
]
@@ -311,5 +775,20 @@ async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
assert "createBackgroundIssueSync({" in html
assert "backgroundSync: backgroundIssueSync" in html
assert "registration.sync.register('stackchain-issue-outbox-v1')" in html
assert "backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))" in html
assert "backgroundIssueSync.snapshot().then(records => {" in html
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")
+60 -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,61 @@ 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_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
+161
View File
@@ -63,3 +63,164 @@ const poller = createContextPoller({{
"callsWhileHidden": 1,
"callsAfterResume": 2,
}
def test_context_poller_sends_revisions_and_merges_changed_sections():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const requested = [];
const rendered = [];
const responses = [
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
freshness: {{ age_seconds: 1 }} }},
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
];
const poller = createContextPoller({{
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
onSnapshot: (snapshot, changed) => rendered.push({{
context: snapshot.context.user.login,
event: snapshot.events[0].id,
age: snapshot.freshness.age_seconds,
changed,
}}),
onError: error => {{ throw error; }},
setTimer: () => 1,
clearTimer: () => {{}},
}});
(async () => {{
await poller.refresh();
await poller.refresh();
await poller.refresh();
process.stdout.write(JSON.stringify({{ requested, rendered }}));
}})();
"""
assert run_node(script) == {
"requested": [
{},
{"context": 1, "events": 1, "notifications": 1},
{"context": 1, "events": 2, "notifications": 1},
],
"rendered": [
{"context": "timmy", "event": 1, "age": 0,
"changed": ["context", "events", "notifications"]},
{"context": "timmy", "event": 2, "age": 1,
"changed": ["events"]},
{"context": "timmy", "event": 2, "age": 2, "changed": []},
],
}
def test_context_poller_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": [],
}
File diff suppressed because it is too large. Load diff
+276
View File
@@ -0,0 +1,276 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
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: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: 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; }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
cookie: () => 'other=x; stackchain_csrf=csrf-proof; theme=dark',
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 || {{}})), body: options.body || null }});
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);
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
setTimeout(() => {{
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
}}, 20);
return request;
}} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, 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),
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); }});
"""
result = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(result.stdout)
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
await boundary.fetch('/dashboard/api/v1/notifications/7/read', { method: 'PATCH' });
process.stdout.write(JSON.stringify(state));
"""
)
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
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_replaces_dashboard_once_without_clearing_private_work():
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());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
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_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(
"""
await boundary.signOut();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/session"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["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
async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
html = await dashboard()
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
+20 -1
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,22 @@ process.stdout.write(JSON.stringify(drafts));
assert output[1]["label"] == "Queued issue"
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))});
@@ -160,6 +176,9 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
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 "Verified not posted — retry" 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
+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
+159 -1
View File
@@ -915,7 +915,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 +955,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 +1051,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):
+134 -4
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"
@@ -110,9 +110,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 +133,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 +249,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))});
@@ -263,7 +390,10 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
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(captureDraft)" 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}
+150
View File
@@ -0,0 +1,150 @@
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"]
@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"],
},
)
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 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"
+292
View File
@@ -0,0 +1,292 @@
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",
}]}
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
assert result["status"] == "saved"
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,
}
],
"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
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-v54" in source
assert "BASE + 'static/later-sync.js'" in source
+43
View File
@@ -21,6 +21,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 +38,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 +90,43 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}
assert result["revisions"] == {
"context": 1, "events": 1, "notifications": 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
+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-v54" 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-v54" 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
+1 -1
View File
@@ -4,7 +4,7 @@ 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"
+773 -5
View File
@@ -1,13 +1,17 @@
import json
import os
import subprocess
from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
@@ -15,6 +19,94 @@ PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
let claims = 0;
let finishClaim;
const states = [];
const controller = createUpdateOwnership({{
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
addToday: () => 'added',
onClaimed: item => states.push({{status:'reconciled', item}}),
onState: state => states.push(state),
}});
controller.open({{
repository:'stackchain/api', title:'Retry deploy',
issue:{{number:7, assignees:[], claimable:true}},
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
const first = controller.act();
const second = controller.act();
if (first !== second || claims !== 1) throw new Error('claim was not single-flight');
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
(async () => {{
await first;
const added = await controller.act();
process.stdout.write(JSON.stringify({{claims, added, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["claims"] == 1
assert output["added"] == "added"
assert output["states"][0] == {"action": "claim", "busy": False, "message": ""}
assert {"action": "claim", "busy": True, "message": "Assigning…"} in output["states"]
reconciled = next(state for state in output["states"] if state.get("status") == "reconciled")
assert reconciled["item"]["notification_id"] == 42
assert reconciled["item"]["repository"] == "stackchain/api"
assert reconciled["item"]["kind"] == "issue"
assert output["states"][-1] == {
"action": "today", "busy": False, "message": "Added to Today."
}
def test_update_ownership_conflict_removes_stale_action_without_reconciling():
script = f"""
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
const states = [];
let reconciled = 0;
const conflict = new Error('server wording'); conflict.status = 409;
const controller = createUpdateOwnership({{
claim: () => Promise.reject(conflict), addToday: () => 'added',
onClaimed: () => {{ reconciled += 1; }}, onState: state => states.push(state),
}});
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
(async () => {{
const result = await controller.act();
process.stdout.write(JSON.stringify({{result, reconciled, states}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["result"] == "conflict"
assert output["reconciled"] == 0
assert output["states"][-1] == {
"action": "hidden",
"busy": False,
"message": "Someone else claimed or closed this issue. The update is still unread.",
}
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
html = await dashboard()
assert '<script src="static/update-ownership.js"></script>' in html
assert 'id="update-ownership-action"' in html
assert 'hidden aria-describedby="update-sheet-status"' in html
assert 'const updateOwnership = createUpdateOwnership({' in html
assert 'updateOwnership.open(detail, selectedUpdate)' in html
assert "qs('#update-ownership-action').addEventListener('click'" in html
assert "lastContextSnapshot.issues" in html
assert "todayWork.add(item)" in html
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
@@ -310,6 +402,58 @@ process.stdout.write(JSON.stringify(queue.map(item => ({{title:item.title,reason
]
def test_attention_includes_only_assigned_deadline_and_priority_critical_work():
payload = {
"user": {"login": "timmy"},
"issues": [
{"number": 1, "title": "Assigned P0", "repository": "stackchain/api",
"labels": ["P0"], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
{"number": 2, "title": "Assigned overdue", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-06T23:59:59Z"},
{"number": 3, "title": "Assigned due today", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-07T23:59:59Z"},
{"number": 4, "title": "Assigned future", "repository": "stackchain/api",
"labels": [], "assignees": ["timmy"], "due_date": "2026-08-10T23:59:59Z"},
{"number": 5, "title": "Unassigned P0", "repository": "stackchain/api",
"labels": ["critical"], "assignees": [], "due_date": "2026-08-10T23:59:59Z"},
{"number": 6, "title": "Unassigned overdue", "repository": "stackchain/api",
"labels": [], "assignees": [], "due_date": "2026-08-06T23:59:59Z"},
],
"pull_requests": [
{"number": 7, "title": "Updated review", "repository": "stackchain/web",
"labels": [], "assignees": [], "work_reasons": ["review_requested"]},
],
"notifications": [
{"id": 70, "number": 7, "repository": "stackchain/web", "unread": True,
"subject_type": "PullRequest", "url": "https://forge.example/web/pulls/7"},
],
}
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = buildMyWork({json.dumps(payload)}, new Date('2026-08-07T12:00:00Z'));
const attention = buildMyWork.filterMyWork(items, 'attention');
process.stdout.write(JSON.stringify({{
titles: attention.map(item => item.title),
count: buildMyWork.countMyWork(items).attention,
reasons: Object.fromEntries(attention.map(item => [item.title, item.attention_reason])),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"titles": ["Assigned P0", "Updated review", "Assigned overdue", "Assigned due today"],
"count": 4,
"reasons": {
"Assigned P0": "P0 priority",
"Updated review": "Unread update",
"Assigned overdue": "Overdue",
"Assigned due today": "Due today",
},
}
def test_confirmed_issue_labels_replace_snapshot_and_reprioritize_queue():
payload = {
"user": {"login": "timmy"},
@@ -415,6 +559,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
assert result.stdout == "Issue release was not confirmed."
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.handoff(item, 'alex', 'timmy');
const duplicate = controller.handoff(item, 'alex', 'timmy');
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, same: first === duplicate, results
}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["same"] is True
assert len(output["calls"]) == 1
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
assert output["calls"][0]["options"]["method"] == "PATCH"
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
assert output["results"][0]["assignees"] == ["alex"]
def test_issue_handoff_candidates_use_the_assigned_issue_route():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let call;
const controller = createIssueSheet({{
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
storage: null,
}});
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -521,6 +717,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
html = await dashboard()
assert 'id="issue-handoff-recipient"' in html
assert 'id="load-issue-handoff"' in html
assert 'id="confirm-issue-handoff"' in html
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
assert "issueController.handoff(selectedIssue, recipient" in html
@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
html = await dashboard()
@@ -544,7 +753,7 @@ async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
assert 'id="resume-issue-draft"' in html
assert 'id="shared-content-conflict"' in html
assert '.shared-content-actions button { min-height:44px;' in html
assert "history.replaceState({}, '', cleanUrl)" in html
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
assert "navigator.serviceWorker.register('service-worker.js')" in html
@@ -621,7 +830,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands()
assert 'loadIssueLabelEditor(' not in open_handler
assert 'loadIssueMilestoneEditor(' not in open_handler
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
assert "qs('#issue-planning').addEventListener('toggle'" in html
assert 'planningLoader.open(selectedIssue)' in html
assert 'id="retry-issue-planning"' in html
@@ -677,6 +886,320 @@ process.stdout.write(JSON.stringify({{
}
def test_later_queue_defers_work_locally_and_scopes_it_to_confirmed_login():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let login = 'timmy';
const item = {{kind:'pull',is_review:true,repository:'stackchain/api',number:17,title:'Review me'}};
const store = createLaterWork({{storage,getLogin:() => login,now:() => new Date('2026-08-08T12:00:00Z')}});
const deferred = store.defer(item, new Date('2026-08-08T16:00:00Z'));
const timmy = store.partition([item]);
login = 'alexander';
const alexander = store.partition([item]);
process.stdout.write(JSON.stringify({{
deferred,
timmy:{{active:timmy.active.length,later:timmy.later}},
alexander:{{active:alexander.active.length,later:alexander.later.length}},
keys:Array.from(values.keys()),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"deferred": "deferred",
"timmy": {
"active": 0,
"later": [{
"kind": "pull", "is_review": True, "repository": "stackchain/api",
"number": 17, "title": "Review me", "deferred_until": "2026-08-08T16:00:00.000Z",
}],
},
"alexander": {"active": 1, "later": 0},
"keys": ["stackchain.later-work.v1.timmy"],
}
def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
let login = '';
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
const unavailable = createLaterWork({{
storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
}});
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
login = 'timmy';
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"noIdentity": "unavailable",
"invalid": "invalid",
"failedWrite": "unavailable",
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let clock = new Date('2026-08-08T12:00:00Z');
let scheduled = null;
let wakes = 0;
const store = createLaterWork({{
storage, getLogin:() => 'timmy', now:() => clock,
setTimer:(callback, delay) => {{ scheduled = {{callback,delay}}; return 7; }},
clearTimer:() => {{}}, onWake:() => {{ wakes += 1; }},
}});
const kept = {{kind:'issue',repository:'stackchain/api',number:17,title:'Kept'}};
const gone = {{kind:'issue',repository:'stackchain/api',number:18,title:'Gone'}};
store.defer(kept, new Date('2026-08-08T13:00:00Z'));
store.defer(gone, new Date('2026-08-09T13:00:00Z'));
const before = store.partition([kept]);
const persisted = JSON.parse(values.get('stackchain.later-work.v1.timmy'));
clock = new Date('2026-08-08T13:00:01Z');
scheduled.callback();
const after = store.partition([kept]);
process.stdout.write(JSON.stringify({{
before:{{active:before.active.length,later:before.later.length}},
persisted:Object.keys(persisted), delay:scheduled.delay, wakes,
after:{{active:after.active.map(item => item.title),later:after.later.length}},
storageEmpty:!values.has('stackchain.later-work.v1.timmy'),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"before": {"active": 0, "later": 1},
"persisted": ["issue:stackchain/api:17:"],
"delay": 3600000,
"wakes": 1,
"after": {"active": ["Kept"], "later": 0},
"storageEmpty": True,
}
def test_later_queue_preserves_rank_and_retains_unloaded_paginated_work():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const store = createLaterWork({{
storage,getLogin:() => 'timmy',now:() => new Date('2026-08-08T12:00:00Z'),
setTimer:() => 1, clearTimer:() => {{}},
}});
const first = {{kind:'issue',repository:'stackchain/api',number:1,title:'Higher priority'}};
const second = {{kind:'pull',repository:'stackchain/api',number:2,title:'Lower priority'}};
store.defer(second, new Date('2026-08-09T12:00:00Z'));
store.defer(first, new Date('2026-08-09T12:00:00Z'));
store.partition([first], {{pruneMissing:false}});
const visible = store.partition([first, second], {{pruneMissing:false}});
process.stdout.write(JSON.stringify({{
titles:visible.later.map(item => item.title),
records:Object.keys(JSON.parse(values.get('stackchain.later-work.v1.timmy'))).length,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"titles": ["Higher priority", "Lower priority"],
"records": 2,
}
def test_later_queue_presets_and_bring_back_now_preserve_work_identity():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const now = new Date('2026-08-08T12:00:00Z');
const store = createLaterWork({{storage,getLogin:() => 'timmy',now:() => now}});
const item = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const today = store.presetUntil('today');
const tomorrow = store.presetUntil('tomorrow');
store.defer(item, tomorrow);
const removed = store.restore(item);
process.stdout.write(JSON.stringify({{
today:today.toISOString(), tomorrow:tomorrow.toISOString(), removed,
partition:store.partition([item]),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True,
env={**os.environ, "TZ": "UTC"},
)
assert json.loads(result.stdout) == {
"today": "2026-08-08T16:00:00.000Z",
"tomorrow": "2026-08-09T09:00:00.000Z",
"removed": True,
"partition": {"active": [{
"kind": "update", "repository": "stackchain/api", "number": 17,
"notification_id": 91,
}], "later": []},
}
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
let sessionActive = false;
const controller = createDetailDefer({{
laterWork: {{
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
}},
session: {{ active:() => sessionActive }},
close:() => calls.push(['close']),
refresh:() => calls.push(['refresh']),
focus:() => calls.push(['focus']),
announce:message => calls.push(['announce', message]),
formatTime:value => value.toISOString(),
}});
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
const outside = controller.defer(item, 'today');
sessionActive = true;
const session = controller.defer(item, 'tomorrow');
process.stdout.write(JSON.stringify({{outside,session,calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"outside": True,
"session": True,
"calls": [
["defer", "Read first", "2026-08-08T16:00:00.000Z"],
["close"],
["refresh"],
["announce", "Deferred until 2026-08-08T16:00:00.000Z. It stays unread and unchanged in Gitea."],
["focus"],
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
["refresh"],
["announce", "Deferred until 2026-08-09T09:00:00.000Z. It stays unread and unchanged in Gitea."],
],
}
def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const controller = createDetailDefer({{
laterWork: {{
presetUntil:() => new Date('2026-08-08T16:00:00Z'),
defer:() => 'unavailable',
}},
session: {{active:() => false}},
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
focus:() => calls.push('focus'), announce:message => calls.push(message),
}});
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
process.stdout.write(JSON.stringify({{saved,calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"saved": False,
"calls": ["Could not save Later on this device."],
}
@pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
html = await dashboard()
assert '<script src="static/detail-defer.js"></script>' in html
assert html.count('class="detail-defer"') == 4
assert html.count('data-detail-defer-preset="today"') == 4
assert html.count('data-detail-defer-preset="tomorrow"') == 4
assert 'const detailDefer = createDetailDefer({' in html
assert 'selectedUpdate || selectedReview || selectedIssue || selectedPull' in html
assert "detailDefer.defer(item, button.dataset.detailDeferPreset)" in html
assert ".detail-defer summary, .detail-defer button { min-height:44px;" in html
assert "fetch(" not in DETAIL_DEFER.read_text()
@pytest.mark.anyio
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
html = await dashboard()
assert '<script src="static/later-work.js"></script>' in html
assert 'data-work-filter="later"' in html
assert 'data-work-count="later"' in html
assert 'const laterWork = createLaterWork({' in html
assert 'getLogin: () => planningOwnerLogin' in html
assert 'laterWork.partition(lastMyWork,' in html
assert 'data-later-preset="today"' in html
assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' in html
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
assert "const result = laterWork.defer(item, until)" in html
assert 'laterWork.restore(item)' in html
assert '.later-actions button { min-height:44px;' in html
assert "'Deferred until ' + fmt(until)" in html
@pytest.mark.anyio
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
html = await dashboard()
assert '<script src="static/later-picker.js"></script>' in html
assert html.count('<button type="button" data-detail-defer-custom') == 4
assert 'data-later-custom' in html
assert 'id="later-picker"' in html
assert 'role="dialog"' in html
assert 'type="datetime-local"' in html
assert 'id="later-picker-timezone"' in html
assert 'id="later-picker-error"' in html
assert 'createLaterPicker({' in html
assert "laterPicker.open(item, button, 'detail')" in html
assert "laterPicker.open(item, button, 'card')" in html
assert "detailDefer.deferUntil(item, until, {" in html
assert "laterWork.defer(item, until)" in html
assert "fetch(" not in LATER_PICKER.read_text()
assert '.later-picker-panel' in html
assert '.later-picker { box-sizing:border-box; width:100%; height:100%;' in html
assert 'env(safe-area-inset-bottom)' in html
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
payload = {
"issues": [
@@ -1168,7 +1691,7 @@ async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requ
assert 'aria-controls="' in html
assert "const detailId = 'find-work-detail-' + index" in html
assert 'class="find-work-detail"' in html
assert "escapeHtml(item.body || 'No description provided.')" in html
assert "renderMarkdown(item.body || 'No description provided.')" in html
assert '>Open in Gitea</a>' in html
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
assert '.find-work-detail { min-width:0;' in html
@@ -1443,6 +1966,113 @@ Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.lo
assert output["stored"]["number"] == 221
def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const calls = [];
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: url => {{
calls.push(url);
return Promise.resolve({{items:[
{{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
{{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
{{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
{{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
{{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
{{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
{{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
]}});
}},
}});
capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state =>
process.stdout.write(JSON.stringify({{calls, state}}))
);
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == [
"api/v1/search?q=Investigate%20checkout%20latency&limit=10"
]
assert output["state"]["status"] == "ready"
assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]
def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const pending = [];
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
}});
const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
const first = capture.findDuplicates(firstDraft);
const latest = capture.findDuplicates(latestDraft);
pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
latest.then(latestState => {{
const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
capture.acknowledgeDuplicates(latestDraft);
const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
const changedSearch = capture.findDuplicates(changedDraft);
pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
changedSearch.then(() => {{
const changed = capture.needsDuplicateAcknowledgement(changedDraft);
pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
first.then(staleState => {{
const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
pending[3].reject(new Error('offline'));
failure.then(failedState => process.stdout.write(JSON.stringify({{
latestState, staleState, blocked, allowed, changed, failedState,
failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
}})));
}});
}});
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["latestState"]["status"] == "ready"
assert output["staleState"]["status"] == "stale"
assert output["blocked"] is True
assert output["allowed"] is False
assert output["changed"] is True
assert output["failedState"]["status"] == "failed"
assert output["failureBlocks"] is False
def test_issue_capture_keeps_partial_duplicate_search_advisory():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
const capture = createIssueCapture({{
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
fetchJson: () => Promise.resolve({{partial:true, items:[
{{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
]}}),
}});
capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
state, blocked:capture.needsDuplicateAcknowledgement(draft),
}})));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["state"]["partial"] is True
assert [item["number"] for item in output["state"]["candidates"]] == [24]
assert output["blocked"] is False
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
payload = {
"user": {"login": "timmy"},
@@ -1860,6 +2490,98 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const saved = {{
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
}};
let detailLoads = 0;
let conversationLoads = 0;
let markReads = 0;
const details = [];
const conversations = [];
const reader = build.createNotificationReader({{
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
markRead: async () => {{ markReads += 1; }}, createPager,
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open(item, saved);
const older = await reader.loadOlder();
const marked = await reader.markReadAndNext([item]);
process.stdout.write(JSON.stringify({{
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"opened": True,
"older": False,
"marked": False,
"detailLoads": 0,
"conversationLoads": 0,
"markReads": 0,
"details": ["Saved update"],
"conversations": [[41]],
}
def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'update', notification_id:42, has_update:true, title:'First'}},
{{kind:'update', notification_id:43, has_update:true, title:'Second'}},
];
const saved = new Map([
[42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
[43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
]);
const events = [];
const reader = build.createNotificationReader({{
load: async () => {{ throw new Error('network must not run'); }},
markRead: async () => {{ throw new Error('network must not run'); }},
queueRead: async id => events.push(['queued', id]),
loadSaved: item => saved.get(item.notification_id),
onOpen: item => events.push(['open', item.notification_id]),
onDetail: detail => events.push(['detail', detail.id]),
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
}});
(async () => {{
await reader.open(items[0], saved.get(42));
events.length = 0;
const result = await reader.markReadAndNext(items);
process.stdout.write(JSON.stringify({{result, events}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["result"]["next"]["notification_id"] == 43
assert output["events"] == [
["status", "Queueing update read…"],
["queued", 42],
["items", [43]],
["open", 43],
["status", "Loading update…"],
["detail", 43],
["status", "Update ready."],
]
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
@@ -2235,6 +2957,31 @@ Promise.all([first, duplicate]).then(async comments => {{
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const controller = createPullSheet({{
storage: null,
fetchJson: () => Promise.resolve({{
number: 7,
merged: false,
state: 'unknown',
confirmation_pending: true,
error: 'Merge confirmation is pending. Check its status before retrying.',
}}),
}});
controller.merge({{repository:'stackchain/api', number:7}}, 'abc123')
.then(() => process.stdout.write(JSON.stringify({{resolved:true}})))
.catch(error => process.stdout.write(JSON.stringify({{resolved:false, message:error.message}})));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"resolved": False,
"message": "Merge confirmation is pending. Check its status before retrying.",
}
def test_pull_sheet_enables_merge_only_for_current_safe_state():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -2508,6 +3255,25 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
assert '.create-issue-label-option' in html and 'min-height:44px' in html
@pytest.mark.anyio
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
html = await dashboard()
assert 'id="create-issue-duplicates"' in html
assert 'id="create-issue-duplicate-list"' in html
assert 'aria-live="polite"' in html
assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
assert "issueCapture.findDuplicates(draft)" in html
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
"issueOutbox.enqueueDurably(captureDraft)"
)
assert ".create-issue-duplicate-card" in html
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
assert "overflow-wrap:anywhere" in html
@pytest.mark.anyio
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
html = await dashboard()
@@ -2671,12 +3437,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
assert 'data-work-count="all"' in html
assert 'data-work-count="today"' in html
assert 'data-work-count="attention"' in html
assert 'data-work-count="issue"' in html
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="later"' in html
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+11 -1
View File
@@ -36,7 +36,12 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))]
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
if request.url.path.endswith("/repos/stackchain/api/issues/7"):
return httpx.Response(200, json={"body": "Deploy fails after retries."})
return httpx.Response(200, json={
"number": 7,
"state": "open",
"assignees": [],
"body": "Deploy fails after retries.",
})
if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"):
return httpx.Response(200, json=comment(47))
raise AssertionError(f"unexpected request: {request.url}")
@@ -56,6 +61,11 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
"older_page": 2,
"total": 47,
}
assert result["issue"] == {
"number": 7,
"assignees": [],
"claimable": True,
}
@pytest.mark.anyio
+135
View File
@@ -0,0 +1,135 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
OUTBOX = ROOT / "frontend" / "notification-read-outbox.js"
BACKGROUND_SYNC = ROOT / "frontend" / "background-issue-sync.js"
WORKER = ROOT / "frontend" / "service-worker.js"
def run_node(script):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_offline_read_admission_is_account_bound_deduplicated_and_suppresses_saved_updates():
script = f"""
const createOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
}};
const reconciled = [];
const backgroundSync = {{
reconcile: async (items, lane) => reconciled.push([lane, items]),
requestSync: async () => true,
}};
const outbox = createOutbox({{
storage, backgroundSync, getOwnerLogin:() => 'timmy', now:() => 1234,
}});
(async () => {{
const first = await outbox.enqueueDurably(42);
const duplicate = await outbox.enqueueDurably(42);
const visible = outbox.suppress([
{{notification_id:42, title:'Queued'}}, {{notification_id:43, title:'Visible'}},
]);
process.stdout.write(JSON.stringify({{
first, duplicate, items:outbox.list(), visible, reconciled,
}}));
}})();
"""
output = run_node(script)
assert output["first"]["item"]["notificationId"] == 42
assert output["duplicate"]["item"]["id"] == output["first"]["item"]["id"]
assert len(output["items"]) == 1
assert output["items"][0]["ownerLogin"] == "timmy"
assert [item["notification_id"] for item in output["visible"]] == [43]
assert output["reconciled"][-1][0] == "notification-read"
assert len(output["reconciled"][-1][1]) == 1
def test_foreground_flush_patches_each_unique_read_and_keeps_transient_failures_queued():
script = f"""
const createOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:key => values.get(key) || null,setItem:(key,value) => values.set(key,value)}};
const calls = [];
const outbox = createOutbox({{
storage, getOwnerLogin:() => 'timmy',
fetchJson: async (url, options) => {{
calls.push([url, options.method]);
if (url.includes('/43/')) {{ const error = new Error('offline'); error.status = 503; throw error; }}
return {{ok:true}};
}},
}});
(async () => {{
await outbox.enqueueDurably(42);
await outbox.enqueueDurably(43);
const result = await outbox.flush('timmy');
process.stdout.write(JSON.stringify({{calls, result, items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert output["calls"] == [
["api/v1/notifications/42/read", "PATCH"],
["api/v1/notifications/43/read", "PATCH"],
]
assert output["result"]["confirmed"] == [42]
assert [item["notificationId"] for item in output["items"]] == [43]
assert output["items"][0]["status"] == "queued"
def test_background_delivery_uses_patch_and_reports_read_receipt():
script = f"""
const createSync = require({json.dumps(str(BACKGROUND_SYNC))});
const requests = [];
const store = {{
upsert:async () => {{}}, claim:async () => ({{
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
ownerLogin:'timmy', status:'sending',
}}), complete:async () => {{}}, release:async () => {{}}, fail:async () => {{}},
}};
const sync = createSync({{
store,
fetchJson:async (url, options) => {{ requests.push([url, options.method, options.body || null]); return {{ok:true}}; }},
}});
(async () => {{
const result = await sync.send({{
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
ownerLogin:'timmy', status:'queued',
}}, 'timmy');
process.stdout.write(JSON.stringify({{requests, result}}));
}})();
"""
output = run_node(script)
assert output["requests"] == [["api/v1/notifications/42/read", "PATCH", None]]
assert output["result"]["receipt"] == {
"id": "notification-read:timmy:42",
"status": "confirmed",
"kind": "notification-read",
"route": "#/my-work/updates",
}
@pytest.mark.anyio
async def test_dashboard_wires_offline_queue_read_next_through_precached_background_outbox():
html = await dashboard()
worker = WORKER.read_text()
assert '<script src="static/notification-read-outbox.js"></script>' in html
assert "const notificationReadOutbox = createNotificationReadOutbox({" in html
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
assert "notificationReadOutbox.suppress(saved.notifications || [])" in html
assert "notificationReadOutbox.flush(activeFlushLogin)" in html
assert "Queue read & next" in html
assert "BASE + 'static/notification-read-outbox.js'" in worker
+150
View File
@@ -0,0 +1,150 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
OFFLINE_TODAY = Path(__file__).parents[1] / "frontend" / "offline-today.js"
def run_scenario(scenario: str) -> dict:
script = f"""
const createOfflineToday = require({json.dumps(str(OFFLINE_TODAY))});
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_warms_bounded_today_queue_with_two_concurrent_requests():
result = run_scenario("""
let active = 0;
let peak = 0;
const loaded = [];
const saved = [];
const items = Array.from({length:7}, (_, index) => ({
kind:index % 2 ? 'pull' : 'issue', repository:'stackchain/dashboard', number:index + 1,
updated_at:'2026-08-08T12:0' + index + ':00Z',
}));
const warmer = createOfflineToday({
loadDetail: async item => {
active += 1;
peak = Math.max(peak, active);
loaded.push(item.number);
await new Promise(resolve => setTimeout(resolve, 5));
active -= 1;
return {title:'Work ' + item.number, body:'Context ' + item.number};
},
loadSavedDetail: () => null,
saveDetail: (login, item, detail) => saved.push({login, number:item.number, detail}),
});
const status = await warmer.warm('timmy', items);
process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
""")
assert result["peak"] == 2
assert result["loaded"] == [1, 2, 3, 4, 5]
assert [entry["number"] for entry in result["saved"]] == [1, 2, 3, 4, 5]
assert result["saved"][0]["detail"]["source_updated_at"] == "2026-08-08T12:00:00Z"
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
result = run_scenario("""
const items = [
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'new'},
{kind:'pull', repository:'stackchain/dashboard', number:2, updated_at:'same'},
];
const old = new Map([
['issue:stackchain/dashboard:1', {title:'Prior 1', source_updated_at:'old'}],
['pull:stackchain/dashboard:2', {title:'Prior 2', source_updated_at:'same'}],
]);
const loaded = [];
const warmer = createOfflineToday({
loadDetail: async item => { loaded.push(item.number); throw new Error('temporary outage'); },
loadSavedDetail: (_login, item) => old.get(item.kind + ':' + item.repository + ':' + item.number),
saveDetail: () => { throw new Error('must not replace a prior copy after failure'); },
});
const status = await warmer.warm('timmy', items);
process.stdout.write(JSON.stringify({loaded, status, failed:warmer.failedKeys()}));
""")
assert result == {
"loaded": [1],
"status": {"total": 2, "ready": 2, "failed": 1, "pending": 0},
"failed": ["issue:stackchain/dashboard:1"],
}
def test_retry_fetches_only_failed_items():
result = run_scenario("""
const items = [
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v2'},
{kind:'issue', repository:'stackchain/dashboard', number:2, updated_at:'v1'},
];
let fail = true;
const calls = [];
const saved = new Map([['issue:stackchain/dashboard:2', {source_updated_at:'v1'}]]);
const warmer = createOfflineToday({
loadDetail: async item => {
calls.push(item.number);
if (fail) throw new Error('offline');
return {title:'Fresh'};
},
loadSavedDetail: (_login, item) => saved.get(item.kind + ':' + item.repository + ':' + item.number),
saveDetail: (_login, item, detail) => saved.set(item.kind + ':' + item.repository + ':' + item.number, detail),
});
await warmer.warm('timmy', items);
fail = false;
const status = await warmer.retry('timmy', items);
process.stdout.write(JSON.stringify({calls, status}));
""")
assert result["calls"] == [1, 1]
assert result["status"] == {"total": 2, "ready": 2, "failed": 0, "pending": 0}
def test_requested_reviews_are_warmed_as_distinct_today_items():
result = run_scenario("""
const loaded = [];
const saved = [];
const review = {
kind:'pull', is_review:true, repository:'stackchain/dashboard', number:12,
updated_at:'2026-08-08T12:00:00Z',
};
const warmer = createOfflineToday({
loadDetail: async item => {
loaded.push(item.is_review ? 'review' : item.kind);
return {head_sha:'abc123', files:[{filename:'src/app.js'}]};
},
loadSavedDetail: () => null,
saveDetail: (_login, item, detail) => saved.push({item, detail}),
});
const status = await warmer.warm('timmy', [review]);
process.stdout.write(JSON.stringify({loaded, saved, status}));
""")
assert result["loaded"] == ["review"]
assert result["saved"][0]["item"]["is_review"] is True
assert result["saved"][0]["detail"]["head_sha"] == "abc123"
assert result["status"] == {"total": 1, "ready": 1, "failed": 0, "pending": 0}
@pytest.mark.anyio
async def test_dashboard_warms_today_after_fresh_snapshot_and_exposes_mobile_retry():
html = await dashboard()
assert '<script src="static/offline-today.js"></script>' in html
assert 'id="offline-today-status"' in html
assert 'id="retry-offline-today"' in html
assert "offlineToday.warm(confirmedOwnerLogin, todayMyWork)" in html
assert "offlineToday.retry(confirmedOwnerLogin, todayMyWork)" in html
assert "Today offline: " in html
assert ".offline-today-readiness button { min-height:44px;" in html
+164 -2
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
@@ -87,6 +87,115 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load(
assert result == {"enabled": False, "loaded": None}
def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2});
store.setEnabled(true);
const issue = {kind:'issue', repository:'stackchain/dashboard', number:8};
store.saveDetail('timmy', issue, {
title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'],
url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}],
conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z',
token:'comment-secret'}], page:1, older_page:null, total:1},
});
store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'});
store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
evicted:store.loadDetail('timmy', issue),
loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}),
wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}),
raw,
}));
""")
assert result["evicted"] is None
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "PR 9"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const update = {kind:'update', notification_id:42};
store.saveDetail('timmy', update, {
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
conversation:{
comments:Array.from({length:25}, (_, index) => ({
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
})),
page:1, older_page:2, total:40,
},
});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
loaded:store.loadDetail('timmy', update),
wrongUser:store.loadDetail('alexander', update),
raw,
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "Deployment blocked"
assert result["loaded"]["subject_body"] == "Safe subject context"
assert result["loaded"]["subject_type"] == "Issue"
assert len(result["loaded"]["conversation"]["comments"]) == 20
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
def test_requested_review_cache_is_sha_scoped_allowlisted_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const review = {kind:'pull', repository:'stackchain/dashboard', number:12, is_review:true};
store.saveDetail('timmy', review, {
title:'Review offline work', body:'Review description', author:'alexander', head_sha:'abc123',
ci_state:'success', token:'top-secret', source_updated_at:'2026-08-07T11:00:00Z',
files:Array.from({length:55}, (_, fileIndex) => ({
filename:'src/file-' + fileIndex + '.js', status:'modified', additions:2, deletions:1,
diff_available:true, diff_binary:false, diff_truncated:false, patch:'must-not-persist',
diff_lines:Array.from({length:405}, (_, lineIndex) => '+' + fileIndex + ':' + lineIndex),
})),
reviews:Array.from({length:25}, (_, index) => ({
id:index + 1, state:'COMMENT', body:'Review ' + (index + 1),
submitted_at:'2026-08-07T10:00:00Z', user:{login:'reviewer-' + index, email:'private@example.test'},
token:'review-secret',
})),
});
const loaded = store.loadDetail('timmy', review);
process.stdout.write(JSON.stringify({
loaded,
wrongUser:store.loadDetail('alexander', review),
raw:[...values.values()].join(' '),
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["head_sha"] == "abc123"
assert result["loaded"]["ci_state"] == "success"
assert len(result["loaded"]["files"]) == 50
assert len(result["loaded"]["files"][0]["diff_lines"]) == 400
assert result["loaded"]["files"][0]["filename"] == "src/file-0.js"
assert len(result["loaded"]["reviews"]) == 20
assert result["loaded"]["reviews"][0]["body"] == "Review 6"
assert result["loaded"]["reviews"][0]["user"] == {"login": "reviewer-5"}
assert "top-secret" not in result["raw"]
assert "must-not-persist" not in result["raw"]
assert "private@example.test" not in result["raw"]
assert "review-secret" not in result["raw"]
@pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard()
@@ -112,4 +221,57 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
assert "function renderLiveSnapshot(snapshot, changedSections" in html
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
@pytest.mark.anyio
async def test_saved_today_details_open_offline_without_enabling_server_state_actions():
html = await dashboard()
assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html
assert "Details not saved—reconnect to open this item." in html
assert "openIssueSheet(item, trigger, savedDetail)" in html
assert "openPullSheet(item, trigger, savedDetail)" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html
assert "todayWork.contains(item)" in html
assert "Offline copy · saved " in html
assert "setOfflineDetailControls('issue')" in html
assert "setOfflineDetailControls('pull')" in html
assert "qs('#issue-planning').inert = true;" in html
assert "qs('#issue-handoff').inert = true;" in html
assert "qs('#pull-review').inert = true;" in html
assert "qs('#issue-planning').inert = false;" in html
assert "qs('#pull-review').inert = false;" in html
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html
@pytest.mark.anyio
async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls():
html = await dashboard()
assert "notificationReader.open(item, savedDetail)" in html
assert "openRoutedWork(item, button);" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
assert "Offline update · saved " in html
assert "setOfflineUpdateControls(true)" in html
assert "qs('#mark-update-read-next').disabled = false;" in html
assert "offline ? 'Queue read & next' : 'Mark read & next'" in html
assert "qs('#update-ownership-action').disabled = offline;" in html
assert "qs('#load-older-update-comments').disabled = offline;" in html
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
assert "replies and read acknowledgements queue for sync" in html
@pytest.mark.anyio
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
html = await dashboard()
assert "item.is_review ? reviewController.load(item)" in html
assert "openReviewSheet(item, trigger, savedDetail)" in html
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
assert "cachedDetail || await reviewController.load(selectedReview)" in html
assert "Offline review · saved " in html
assert "Reconnect to validate & submit" in html
assert "qs('#submit-review').disabled = offlineReview;" in html
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
+4 -2
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__).parents[1]
@@ -110,13 +110,15 @@ const seen=[];
const unsubscribe=second.subscribe(change=>seen.push(change));
first.notify('issue');
first.notify('authored');
first.notify('today');
first.notify('later');
unsubscribe();
first.notify('issue');
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
"""
output = run_node(script)
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
assert [change["queue"] for change in output["seen"]] == ["issue", "authored", "today", "later"]
assert output["signal"]["queue"] == "issue"
assert output["signal"]["tabId"] == "first"
+4 -2
View File
@@ -1,11 +1,13 @@
from pathlib import Path
from tests.dashboard_bundle import dashboard_bundle_text
DASHBOARD = Path("frontend/index.html")
def test_dashboard_panels_are_independently_collapsible():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
assert f'<details class="panel' in html
@@ -13,7 +15,7 @@ def test_dashboard_panels_are_independently_collapsible():
def test_panel_open_state_is_restored_and_saved_in_local_storage():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
+49
View File
@@ -0,0 +1,49 @@
import json
import subprocess
from pathlib import Path
PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-device-data.js"
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
harness = f"""
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
const storage = {{
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
get length(){{return this.values.size;}},
key(index){{return Array.from(this.values.keys())[index] || null;}},
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
}};
const clear = createPrivateDeviceDataPurger({{
localStorage:storage, sessionStorage:storage,
indexedDB:{{deleteDatabase:name=>{{
state.databases.push(name);
const request={{}};
setTimeout(()=>{{state.complete=true;request.onsuccess?.();}},10);
return request;
}}}},
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
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;
}}}},
}});
(async()=>{{
await clear();
process.stdout.write(JSON.stringify({{...state,remaining:Array.from(storage.values.keys())}}));
}})().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["remaining"] == ["other.preference"]
assert state["databases"] == ["stackchain-background-outbox-v1"]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert state["complete"] is True
+104
View File
@@ -1,3 +1,5 @@
import asyncio
import httpx
import pytest
@@ -247,6 +249,108 @@ async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
assert calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypatch):
calls = []
merged = False
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
nonlocal merged
calls.append(("merge", repository, number, expected_head_sha))
merged = True
await asyncio.sleep(0.05)
return {"number": number, "merged": True, "state": "closed"}
async def confirm(repository, number, expected_head_sha):
calls.append(("confirm", repository, number, expected_head_sha))
return merged
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm, raising=False)
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/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert calls == [
("merge", "stackchain/api", 7, "abc123"),
("confirm", "stackchain/api", 7, "abc123"),
]
@pytest.mark.anyio
async def test_gitea_merge_confirmation_requires_merged_expected_head():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
return httpx.Response(200, json={
"number": 7,
"state": "closed",
"merged": True,
"head": {"sha": "abc123"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
confirmed = await gitea_proxy.is_pull_merged_at_head(
"stackchain/api", 7, "abc123"
)
finally:
await gitea_proxy.stop_client()
assert confirmed is True
assert requests == [("GET", "/api/v1/repos/stackchain/api/pulls/7")]
@pytest.mark.anyio
async def test_assigned_pull_merge_reports_unresolved_confirmation_without_false_state(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
calls.append(("merge", repository, number, expected_head_sha))
raise httpx.ReadTimeout("upstream response was lost")
async def confirm(repository, number, expected_head_sha):
calls.append(("confirm", repository, number, expected_head_sha))
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm)
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/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 202
assert response.json() == {
"number": 7,
"merged": False,
"state": "unknown",
"confirmation_pending": True,
"error": "Merge confirmation is pending. Check its status before retrying.",
}
assert calls == [
("merge", "stackchain/api", 7, "abc123"),
("confirm", "stackchain/api", 7, "abc123"),
]
@pytest.mark.anyio
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
async def assigned(repository, number):
+25
View File
@@ -32,3 +32,28 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
assert "`/readyz`" in text
assert "does not contact Gitea" in text
assert "HTTP 503" in text
def test_readme_documents_exact_later_return_times():
text = " ".join(README.read_text().split())
assert "Choose date & time" in text
assert "valid future local date and time" in text
assert "exact instant" in text
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
text = " ".join(README.read_text().split())
assert "opened Today issue or pull request" in text
assert "newest 20 comments" in text
assert "comments can enter the account-bound durable outbox" in text
assert "planning, assignment, review, merge, and close controls remain disabled" in text
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
text = " ".join(README.read_text().split())
assert "previously opened unread update" in text
assert "replies enter the account-bound durable outbox" in text
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
+104
View File
@@ -0,0 +1,104 @@
import hashlib
import json
import subprocess
import sys
import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PACKAGER = ROOT / "scripts" / "build_release.py"
VERIFIER = ROOT / "scripts" / "verify_release.py"
def _fixture(root: Path) -> None:
(root / "src").mkdir(parents=True)
(root / "frontend").mkdir()
(root / "src" / "main.py").write_text("print('ready')\n")
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
(root / "requirements.txt").write_text("fastapi==1.0\n")
(root / "README.md").write_text("# Stackchain\n")
def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
sys.executable,
str(PACKAGER),
"--root",
str(source),
"--output-dir",
str(output),
"--commit",
"a" * 40,
"--source-date-epoch",
"1720000000",
],
text=True,
capture_output=True,
check=False,
)
def test_release_bundle_is_reproducible(tmp_path):
source = tmp_path / "source"
_fixture(source)
first = _build(source, tmp_path / "first")
second = _build(source, tmp_path / "second")
assert first.returncode == 0, first.stderr
assert second.returncode == 0, second.stderr
first_archive = next((tmp_path / "first").glob("*.tar.gz"))
second_archive = next((tmp_path / "second").glob("*.tar.gz"))
assert first_archive.read_bytes() == second_archive.read_bytes()
manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text())
checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0]
assert manifest["commit"] == "a" * 40
assert manifest["artifact"]["sha256"] == checksum
assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest()
assert sorted(manifest["files"]) == [
"README.md",
"frontend/index.html",
"requirements.txt",
"src/main.py",
]
with tarfile.open(first_archive, "r:gz") as archive:
names = archive.getnames()
embedded = json.load(archive.extractfile("release-manifest.json"))
assert names == [
"README.md",
"frontend/index.html",
"release-manifest.json",
"requirements.txt",
"src/main.py",
]
assert embedded["commit"] == "a" * 40
assert embedded["files"] == manifest["files"]
def test_release_bundle_verifier_enforces_integrity(tmp_path):
source = tmp_path / "source"
output = tmp_path / "dist"
_fixture(source)
built = _build(source, output)
assert built.returncode == 0, built.stderr
valid = subprocess.run(
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
text=True,
capture_output=True,
check=False,
)
assert valid.returncode == 0, valid.stderr
archive = next(output.glob("*.tar.gz"))
archive.write_bytes(archive.read_bytes() + b"tampered")
tampered = subprocess.run(
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
text=True,
capture_output=True,
check=False,
)
assert tampered.returncode != 0
+567 -6
View File
@@ -1,5 +1,9 @@
import json
import os
import sys
import threading
import time
from dataclasses import replace
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
@@ -12,12 +16,85 @@ from src.release_engine import (
Issue,
ReleaseEngine,
RunState,
ShellRunner,
StateStore,
_parse_args,
branch_name,
select_issue,
)
def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
output = tmp_path / "argument.txt"
injected = tmp_path / "injected.txt"
payload = f"release title; touch {injected}"
result = ShellRunner().run(
[
sys.executable,
"-c",
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])",
str(output),
payload,
],
tmp_path,
)
assert result.returncode == 0
assert output.read_text() == payload
assert not injected.exists()
def test_shell_runner_uses_only_the_explicit_environment(tmp_path, monkeypatch):
output = tmp_path / "environment.json"
monkeypatch.setenv("GITEA_TOKEN", "write-capable-secret")
monkeypatch.setenv("UNRELATED_SECRET", "also-secret")
result = ShellRunner().run(
[
sys.executable,
"-c",
"import json, os, sys; json.dump(dict(os.environ), open(sys.argv[1], 'w'))",
str(output),
],
tmp_path,
{"PATH": os.environ["PATH"], "EXPECTED_VALUE": "available"},
)
environment = json.loads(output.read_text())
assert result.returncode == 0
assert environment["EXPECTED_VALUE"] == "available"
assert "GITEA_TOKEN" not in environment
assert "UNRELATED_SECRET" not in environment
def test_shell_runner_times_out_and_terminates_spawned_children(tmp_path):
marker = tmp_path / "child-survived.txt"
child = (
"import time; from pathlib import Path; "
f"time.sleep(0.3); Path({str(marker)!r}).write_text('survived')"
)
parent = (
"import subprocess, sys, time; "
f"subprocess.Popen([sys.executable, '-c', {child!r}]); "
"time.sleep(10)"
)
started = time.monotonic()
result = ShellRunner().run(
[sys.executable, "-c", parent],
tmp_path,
timeout_seconds=0.05,
termination_grace_seconds=0.05,
)
elapsed = time.monotonic() - started
time.sleep(0.35)
assert result.timed_out is True
assert elapsed < 0.5
assert not marker.exists()
def test_stale_token_error_explains_how_to_restore_cron_authentication():
class UnauthorizedHandler(BaseHTTPRequestHandler):
def do_GET(self):
@@ -70,9 +147,13 @@ class FakeRunner:
def __init__(self, results):
self.results = list(results)
self.commands = []
self.environments = []
self.options = []
def run(self, command, cwd, env=None):
def run(self, command, cwd, env=None, **options):
self.commands.append(command)
self.environments.append(env)
self.options.append(options)
return self.results.pop(0)
@@ -91,7 +172,7 @@ def config(tmp_path):
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command="agent --issue {issue_number}",
agent_command="agent --issue-from-env",
test_command="pytest -q",
)
@@ -121,6 +202,74 @@ def test_claim_verification_blocks_execution(tmp_path):
assert runner.commands == []
def test_empty_agent_command_fails_before_claim(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
cfg = EngineConfig(
repo="stackchain/stackchain-dashboard",
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command=" ",
test_command="pytest -q",
)
with pytest.raises(ValueError, match="invalid agent command: command is empty"):
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
assert client.claimed == []
@pytest.mark.parametrize(
("field", "value"),
[
("agent_timeout_seconds", 0),
("test_timeout_seconds", -1),
("termination_grace_seconds", 0),
],
)
def test_non_positive_command_deadlines_fail_before_claim(tmp_path, field, value):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
cfg = replace(config(tmp_path), **{field: value})
with pytest.raises(ValueError, match=field.replace("_", " ")):
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
assert client.claimed == []
@pytest.mark.parametrize("name", ["GITEA_TOKEN", "RELEASE_REPO", "bad-name", "1START"])
def test_invalid_agent_environment_allowlist_fails_before_claim(tmp_path, name):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
cfg = replace(config(tmp_path), agent_env_vars=(name,))
with pytest.raises(ValueError, match="invalid agent environment variable"):
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
assert client.claimed == []
def test_command_deadlines_are_configurable_from_cli(monkeypatch):
monkeypatch.setattr(sys, "argv", [
"release-engine",
"--repo", "stackchain/stackchain-dashboard",
"--agent-timeout", "120",
"--test-timeout", "45.5",
"--termination-grace", "2",
"--agent-env", "CODEX_HOME",
"--agent-env", "HTTPS_PROXY",
])
args = _parse_args()
assert args.agent_timeout == 120
assert args.test_timeout == 45.5
assert args.termination_grace == 2
assert args.agent_env == ["CODEX_HOME", "HTTPS_PROXY"]
def test_agent_failure_blocks_tests_and_pr(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
@@ -134,18 +283,110 @@ def test_agent_failure_blocks_tests_and_pr(tmp_path):
engine.execute(issue_number=14)
assert runner.commands == [
"git checkout -B timmy/14-engine",
"agent --issue 14",
["git", "checkout", "-B", "timmy/14-engine"],
["agent", "--issue-from-env"],
]
assert client.created_prs == []
def test_agent_timeout_records_terminal_state_and_blocks_tests(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(-9, "partial output", "stalled", timed_out=True),
])
cfg = config(tmp_path)
with pytest.raises(RuntimeError, match="coding agent timed out"):
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
state = StateStore(cfg.state_path).load()
assert state is not None
assert state.status == "agent_timed_out"
assert "partial output" in state.evidence
assert runner.commands == [
["git", "checkout", "-B", "timmy/14-engine"],
["agent", "--issue-from-env"],
]
assert runner.options[1] == {
"timeout_seconds": cfg.agent_timeout_seconds,
"termination_grace_seconds": cfg.termination_grace_seconds,
}
assert client.created_prs == []
def test_issue_content_reaches_agent_only_through_environment(tmp_path):
title = "$(touch /tmp/title-injected); `id`"
body = "line one\n&& touch /tmp/body-injected > stolen"
issue = Issue(14, title, body, "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(1, "", "agent stopped"),
])
cfg = EngineConfig(
repo="stackchain/stackchain-dashboard",
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command="agent --work",
test_command="pytest -q",
)
with pytest.raises(RuntimeError, match="coding agent failed"):
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
assert runner.commands == [
["git", "checkout", "-B", "timmy/14-touch-tmp-title-injected-id"],
["agent", "--work"],
]
assert runner.environments[1]["RELEASE_ISSUE_TITLE"] == title
assert runner.environments[1]["RELEASE_ISSUE_BODY"] == body
def test_release_stages_receive_least_privilege_environments(tmp_path, monkeypatch):
monkeypatch.setenv("GITEA_TOKEN", "write-capable-secret")
monkeypatch.setenv("UNRELATED_SECRET", "also-secret")
monkeypatch.setenv("CODEX_HOME", "/approved/codex")
issue = Issue(14, "Engine MVP", "sensitive body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "implemented", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "8 passed", ""),
CommandResult(0, "", ""),
])
cfg = replace(config(tmp_path), agent_env_vars=("CODEX_HOME",))
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
for environment in runner.environments:
assert environment is not None
assert "PATH" in environment
assert "GITEA_TOKEN" not in environment
assert "UNRELATED_SECRET" not in environment
agent_environment = runner.environments[1]
assert agent_environment["CODEX_HOME"] == "/approved/codex"
assert agent_environment["RELEASE_ISSUE_NUMBER"] == "14"
assert agent_environment["RELEASE_ISSUE_TITLE"] == issue.title
assert agent_environment["RELEASE_ISSUE_BODY"] == issue.body
assert agent_environment["RELEASE_REPO"] == cfg.repo
assert agent_environment["RELEASE_BRANCH"] == "timmy/14-engine-mvp"
assert not any(
name.startswith("RELEASE_")
for name in runner.environments[3]
)
def test_failed_tests_block_push_and_pr(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "changed", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(1, "1 failed", ""),
])
engine = ReleaseEngine(config(tmp_path), client, runner)
@@ -153,7 +394,33 @@ def test_failed_tests_block_push_and_pr(tmp_path):
with pytest.raises(RuntimeError, match="tests failed"):
engine.execute(issue_number=14)
assert "git push" not in " ".join(runner.commands)
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
assert client.created_prs == []
def test_test_timeout_records_terminal_state_and_blocks_push(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "implemented", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(-15, "3 passed before stall", "", timed_out=True),
])
cfg = config(tmp_path)
with pytest.raises(RuntimeError, match="tests timed out"):
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
state = StateStore(cfg.state_path).load()
assert state is not None
assert state.status == "tests_timed_out"
assert "3 passed before stall" in state.evidence
assert runner.options[3] == {
"timeout_seconds": cfg.test_timeout_seconds,
"termination_grace_seconds": cfg.termination_grace_seconds,
}
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
assert client.created_prs == []
@@ -163,6 +430,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "implemented", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "8 passed", ""),
CommandResult(0, "", ""),
])
@@ -171,7 +439,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
result = engine.execute(issue_number=14)
assert result["pr_number"] == 44
assert runner.commands[-1].startswith("git push -u origin")
assert runner.commands[-1] == ["git", "push", "-u", "origin", "timmy/14-engine-mvp"]
assert len(client.created_prs) == 1
body = client.created_prs[0][4]
assert "Closes #14" in body
@@ -193,6 +461,51 @@ def test_dry_run_does_not_claim_or_execute(tmp_path):
assert runner.commands == []
def test_overlapping_run_fails_before_gitea_or_git_side_effects(tmp_path):
entered = threading.Event()
release_owner = threading.Event()
class BlockingClient(FakeClient):
def __init__(self):
super().__init__([])
self.discovery_calls = 0
def list_open_issues(self, repo):
self.discovery_calls += 1
if self.discovery_calls > 1:
raise AssertionError("contended run reached Gitea discovery")
entered.set()
release_owner.wait(timeout=2)
raise RuntimeError("stop lease owner")
client = BlockingClient()
owner_runner = FakeRunner([])
contender_runner = FakeRunner([])
owner = ReleaseEngine(config(tmp_path), client, owner_runner)
contender = ReleaseEngine(config(tmp_path), client, contender_runner)
def run_owner():
with pytest.raises(RuntimeError, match="stop lease owner"):
owner.execute(dry_run=True)
thread = threading.Thread(target=run_owner)
thread.start()
assert entered.wait(timeout=1)
try:
with pytest.raises(RuntimeError, match="release run already active") as error:
contender.execute(dry_run=True)
assert f"owner pid={os.getpid()}" in str(error.value)
assert "acquired_at=" in str(error.value)
finally:
release_owner.set()
thread.join(timeout=2)
assert not thread.is_alive()
assert client.discovery_calls == 1
assert owner_runner.commands == []
assert contender_runner.commands == []
def test_existing_pr_is_reused_without_agent_execution(tmp_path):
issue = Issue(14, "Engine MVP", "body", "open", [], "timmy")
client = FakeClient([issue], existing_pr={"number": 9, "html_url": "https://forge/pr/9"})
@@ -212,3 +525,251 @@ def test_state_store_round_trip_is_atomic(tmp_path):
store.save(expected)
assert store.load() == expected
assert not (tmp_path / "state.json.tmp").exists()
def test_concurrent_checkpoint_writers_never_collide_or_leave_partial_state(tmp_path):
store = StateStore(tmp_path / "state.json")
states = [
RunState(issue=index, branch=f"timmy/{index}-engine", status="tests_passed")
for index in range(8)
]
barrier = threading.Barrier(len(states))
errors = []
def save_repeatedly(state):
barrier.wait()
for _ in range(100):
try:
store.save(state)
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=save_repeatedly, args=(state,)) for state in states]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert not any(thread.is_alive() for thread in threads)
assert errors == []
assert store.load() in states
assert list(tmp_path.glob("state.json.*.tmp")) == []
def test_restart_from_agent_complete_skips_claim_and_coding(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
branch = "timmy/331-resume-releases"
cfg.state_path.write_text(json.dumps({
"schema_version": 1,
"repo": cfg.repo,
"issue": issue.number,
"branch": branch,
"status": "agent_complete",
"evidence": "",
"pr_number": None,
"pr_url": "",
"head_sha": "abc123",
}))
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "", ""),
CommandResult(0, "8 passed", ""),
CommandResult(0, "", ""),
])
result = ReleaseEngine(cfg, client, runner).execute(issue_number=331)
assert result["tests"] == "passed"
assert client.claimed == []
assert ["agent", "--issue-from-env"] not in runner.commands
assert runner.commands[:3] == [
["git", "checkout", branch],
["git", "rev-parse", "HEAD"],
["git", "status", "--porcelain"],
]
def test_restart_from_tests_passed_skips_claim_coding_and_tests(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
branch = "timmy/331-resume-releases"
StateStore(cfg.state_path).save(RunState(
issue=issue.number,
branch=branch,
status="tests_passed",
evidence="604 passed",
repo=cfg.repo,
head_sha="abc123",
))
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "", ""),
CommandResult(0, "", ""),
])
result = ReleaseEngine(cfg, client, runner).execute(issue_number=331)
assert result["tests"] == "passed"
assert client.claimed == []
assert ["agent", "--issue-from-env"] not in runner.commands
assert ["pytest", "-q"] not in runner.commands
assert runner.commands == [
["git", "checkout", branch],
["git", "rev-parse", "HEAD"],
["git", "status", "--porcelain"],
["git", "push", "-u", "origin", branch],
]
def test_restart_from_pushed_verifies_remote_and_only_opens_pr(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
branch = "timmy/331-resume-releases"
StateStore(cfg.state_path).save(RunState(
issue=issue.number,
branch=branch,
status="pushed",
evidence="604 passed",
repo=cfg.repo,
head_sha="abc123",
))
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "", ""),
CommandResult(0, f"abc123\trefs/heads/{branch}\n", ""),
])
result = ReleaseEngine(cfg, client, runner).execute(issue_number=331)
assert result["pr_number"] == 44
assert client.claimed == []
assert len(client.created_prs) == 1
assert runner.commands == [
["git", "checkout", branch],
["git", "rev-parse", "HEAD"],
["git", "status", "--porcelain"],
["git", "ls-remote", "--heads", "origin", branch],
]
def test_fresh_run_persists_repository_and_commit_identity(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], None)
client = FakeClient([issue])
cfg = config(tmp_path)
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "implemented", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "604 passed", ""),
CommandResult(0, "", ""),
])
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
state = StateStore(cfg.state_path).load()
assert state is not None
assert state.status == "pr_opened"
assert state.schema_version == 1
assert state.repo == cfg.repo
assert state.head_sha == "abc123"
assert ["git", "rev-parse", "HEAD"] in runner.commands
def test_resume_fails_closed_when_checkpoint_worktree_is_dirty(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
branch = "timmy/331-resume-releases"
StateStore(cfg.state_path).save(RunState(
issue=issue.number,
branch=branch,
status="tests_passed",
evidence="604 passed",
repo=cfg.repo,
head_sha="abc123",
))
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, " M src/release_engine.py\n", ""),
CommandResult(0, "", ""),
])
with pytest.raises(RuntimeError, match="worktree is not clean"):
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
assert client.created_prs == []
def test_resume_fails_closed_when_checkpoint_targets_another_repository(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
StateStore(cfg.state_path).save(RunState(
issue=issue.number,
branch="timmy/331-resume-releases",
status="tests_passed",
repo="other/repository",
head_sha="abc123",
))
with pytest.raises(RuntimeError, match="checkpoint does not match"):
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=331)
assert client.created_prs == []
def test_resume_fails_closed_when_local_commit_changed(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
StateStore(cfg.state_path).save(RunState(
issue=issue.number,
branch="timmy/331-resume-releases",
status="tests_passed",
repo=cfg.repo,
head_sha="abc123",
))
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "different-sha\n", ""),
])
with pytest.raises(RuntimeError, match="commit does not match the local branch"):
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
assert client.created_prs == []
def test_resume_fails_closed_when_remote_commit_changed(tmp_path):
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
client = FakeClient([issue])
cfg = config(tmp_path)
branch = "timmy/331-resume-releases"
StateStore(cfg.state_path).save(RunState(
issue=issue.number,
branch=branch,
status="pushed",
evidence="604 passed",
repo=cfg.repo,
head_sha="abc123",
))
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(0, "abc123\n", ""),
CommandResult(0, "", ""),
CommandResult(0, f"different-sha\trefs/heads/{branch}\n", ""),
])
with pytest.raises(RuntimeError, match="commit does not match the remote branch"):
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
assert client.created_prs == []
+19 -15
View File
@@ -1,24 +1,28 @@
from pathlib import Path
WORKFLOW = Path(".gitea/workflows/release.yml")
LEGACY_WORKFLOW = Path(".gitea/workflows/release.yml")
CI_WORKFLOW = Path(".gitea/workflows/ci.yml")
STALE_MANIFEST = Path("release/manifest.json")
def test_release_workflow_uses_gitea_api_not_github_cli():
text = WORKFLOW.read_text()
assert "gh release" not in text
assert "/api/v1/repos/${{ github.repository }}/tags" in text
assert "/api/v1/repos/${{ github.repository }}/releases" in text
def test_independent_release_workflow_is_removed():
assert not LEGACY_WORKFLOW.exists()
def test_release_workflow_targets_merge_commit_and_marks_rc():
text = WORKFLOW.read_text()
assert 'TARGET="${{ github.sha }}"' in text
assert '"prerelease":true' in text
assert '"draft":false' in text
def test_stale_static_release_manifest_is_removed():
assert not STALE_MANIFEST.exists()
def test_release_workflow_fails_on_api_error():
text = WORKFLOW.read_text()
assert "curl --fail-with-body" in text
assert "|| true" not in text
def test_ci_publishes_a_draft_before_exposing_assets():
text = CI_WORKFLOW.read_text()
release = text[text.index(" release-candidate:") :]
create_draft = release.index('"draft":true')
upload_asset = release.index("/assets?name=")
publish = release.index('"draft":false')
assert create_draft < upload_asset < publish
assert '"prerelease":true' in release
assert 'TARGET="${{ github.sha }}"' in release
assert "curl --fail-with-body" in release
assert "|| true" not in release
+147
View File
@@ -0,0 +1,147 @@
import httpx
import pytest
from src import main
from src.request_boundary import RequestBodyLimitMiddleware
@pytest.fixture
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")
)
@pytest.mark.anyio
async def test_declared_oversized_sign_in_is_rejected_before_throttle(
access_control, monkeypatch
):
throttle_touched = False
def attempt_store():
nonlocal throttle_touched
throttle_touched = True
raise AssertionError("oversized request reached sign-in throttling")
monkeypatch.setattr(main, "_login_attempt_store", attempt_store)
body = b'{"access_token":"' + (b"x" * 20_000) + b'"}'
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",
content=body,
headers={"Content-Type": "application/json"},
)
assert response.status_code == 413
assert response.json() == {"detail": "Request body too large"}
assert response.headers["cache-control"] == "no-store"
assert response.headers["x-content-type-options"] == "nosniff"
assert len(response.content) < 128
assert throttle_touched is False
@pytest.mark.anyio
async def test_streamed_oversized_sign_in_without_content_length_is_rejected(
access_control, monkeypatch
):
throttle_touched = False
def attempt_store():
nonlocal throttle_touched
throttle_touched = True
raise AssertionError("oversized stream reached sign-in throttling")
async def chunks():
yield b'{"access_token":"'
yield b"x" * 10_000
yield b"y" * 10_000
yield b'"}'
monkeypatch.setattr(main, "_login_attempt_store", attempt_store)
request = httpx.Request(
"POST",
"https://test/api/v1/session",
content=chunks(),
headers={"Content-Type": "application/json"},
)
request.headers.pop("transfer-encoding", None)
transport = httpx.ASGITransport(app=main.app)
response = await transport.handle_async_request(request)
await response.aread()
assert response.status_code == 413
assert response.json() == {"detail": "Request body too large"}
assert response.headers["cache-control"] == "no-store"
assert throttle_touched is False
@pytest.mark.anyio
async def test_validation_error_never_reflects_submitted_values(access_control):
canary = "DO-NOT-REFLECT-ACCESS-TOKEN-CANARY"
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": canary, "device_label": canary * 3},
)
assert response.status_code == 422
assert canary not in response.text
details = response.json()["detail"]
assert details
assert all(set(item) <= {"type", "loc", "msg"} for item in details)
def test_request_limits_are_route_specific_and_cover_api_mutations():
assert main.request_body_limit("POST", "/api/v1/session") == 16 * 1024
assert (
main.request_body_limit("POST", "/api/v1/repos/stackchain/project/issues")
== 64 * 1024
)
assert main.request_body_limit("GET", "/api/v1/context") is None
assert main.request_body_limit("POST", "/unrelated") is None
@pytest.mark.anyio
async def test_request_limit_uses_application_path_under_domain_subpath():
downstream_called = False
sent = []
async def downstream(_scope, _receive, _send):
nonlocal downstream_called
downstream_called = True
async def receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def send(message):
sent.append(message)
middleware = RequestBodyLimitMiddleware(downstream)
await middleware(
{
"type": "http",
"method": "POST",
"root_path": "/dashboard",
"path": "/api/v1/session",
"headers": [(b"content-length", b"20000")],
},
receive,
send,
)
assert sent[0]["status"] == 413
assert downstream_called is False
+59
View File
@@ -0,0 +1,59 @@
import httpx
import pytest
from src import main
REQUIRED_HEADERS = {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"x-frame-options": "DENY",
}
def assert_browser_security_boundary(response: httpx.Response) -> None:
for name, value in REQUIRED_HEADERS.items():
assert response.headers[name] == value
assert "camera=()" in response.headers["permissions-policy"]
policy = response.headers["content-security-policy"]
assert "default-src 'self'" in policy
assert "script-src 'self'" in policy
assert "object-src 'none'" in policy
assert "frame-ancestors 'none'" in policy
script_policy = next(part for part in policy.split(";") if "script-src" in part)
assert "'unsafe-inline'" not in script_policy
assert "'unsafe-eval'" not in script_policy
@pytest.mark.anyio
async def test_security_boundary_covers_pages_health_static_and_api_errors(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
responses = [
await client.get("/healthz"),
await client.get("/login"),
await client.get("/static/dashboard.js"),
await client.get("/api/v1/context"),
]
assert [response.status_code for response in responses] == [200, 200, 200, 401]
for response in responses:
assert_browser_security_boundary(response)
@pytest.mark.anyio
async def test_security_boundary_covers_fail_closed_authentication_response(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/login")
assert response.status_code == 503
assert_browser_security_boundary(response)
+338 -10
View File
@@ -13,21 +13,31 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
}};
const context = {{
URL, Request, Response,
console,
URL, Request, Response, Headers, AbortController,
setTimeout, clearTimeout, console,
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{ claim: async () => {{ state.claimed = true; }} }},
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
clients: {{
claim: async () => {{ state.claimed = true; }},
matchAll: async () => state.clientList || [],
openWindow: async url => {{ state.opened.push(url); }},
}},
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
importScripts: () => {{}},
caches: {{
@@ -36,14 +46,26 @@ const context = {{
delete: async key => {{ state.deleted.push(key); return true; }},
match: async request => cache.match(request),
}},
fetch: async request => {{
fetch: async (request, options = {{}}) => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
return new Response('network', {{ status: state.fetchStatus }});
if (state.stallFetch) return new Promise((resolve, reject) => {{
options.signal?.addEventListener('abort', () => {{
state.fetchAborted = true;
reject(new Error('aborted'));
}}, {{ once: true }});
}});
if (state.lateFetch) return new Promise(resolve => {{
options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }});
setTimeout(() => resolve(new Response('late network')), 50);
}});
const response = new Response('network', {{ status: state.fetchStatus }});
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
return response;
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
async function dispatch(name, request) {{
let pending;
let response;
@@ -60,6 +82,19 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchMessage(data, ports = []) {{
let pending;
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
let pending;
listeners.notificationclick({{
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
@@ -70,10 +105,49 @@ async function dispatchSync(tag) {{
return json.loads(completed.stdout)
def test_background_issue_sync_ships_in_a_new_shell_cache():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v12" in source
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v54" in source
assert "BASE + 'static/update-ownership.js'" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@@ -88,6 +162,153 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
assert result["backgroundFlushes"] == 1
def test_background_delivery_preserves_structured_uncertain_error():
result = run_worker_scenario(
"""
context.fetch=async()=>new Response(JSON.stringify({detail:{code:'delivery_uncertain',message:'Verify it was not posted before retrying.'}}),{status:422,headers:{'Content-Type':'application/json'}});
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
.then(()=>({ok:true}),error=>({status:error.status,code:error.code,message:error.message}));
process.stdout.write(JSON.stringify(outcome));
"""
)
assert result == {
"status": 422,
"code": "delivery_uncertain",
"message": "Verify it was not posted before retrying.",
}
def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_clients():
result = run_worker_scenario(
"""
state.clientMessages=[];
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_revoked'}),{status:401,headers:{'Content-Type':'application/json'}});
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
process.stdout.write(JSON.stringify({state,outcome}));
"""
)
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-revoked"}
]
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-resume-outbox'});
process.stdout.write(JSON.stringify(state));
"""
)
assert result["backgroundFlushes"] == 1
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario(
"""
let mutations=0;
context.fetch=(request, options={})=>{
if(String(request).includes('/api/v1/session')){
return new Promise((resolve,reject)=>options.signal?.addEventListener('abort',()=>reject(new Error('aborted')), {once:true}));
}
mutations+=1;
return Promise.resolve(new Response('{}',{status:200,headers:{'Content-Type':'application/json'}}));
};
const controller=new AbortController();
const request=context.self.__testWithSessionCsrf(() =>
context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST',signal:controller.signal})
).then(()=> 'completed', error=>error.message);
controller.abort();
const outcome=await Promise.race([request,new Promise(resolve=>setTimeout(()=>resolve('blocked'),40))]);
process.stdout.write(JSON.stringify({outcome,mutations}));
process.exit(0);
"""
)
assert result == {"outcome": "aborted", "mutations": 0}
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
result = run_worker_scenario(
"""
const replies = [];
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
process.stdout.write(JSON.stringify({state,replies}));
"""
)
assert result["state"]["outboxPurges"] == 1
assert result["replies"] == [{"ok": True}]
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario(
"""
state.receiptLogin = 'timmy';
state.flushResult = {login:'timmy', receipts:[
{id:'capture-1',status:'confirmed',kind:'issue',route:'#/my-work/issue/stackchain/api/44'},
{id:'bad-1',status:'attention',kind:'message',route:'#/my-work/drafts'},
]};
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
await dispatchSync('stackchain-issue-outbox-v1');
await dispatchNotificationClick('#/my-work/issue/stackchain/api/44');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [
{
"title": "Queued issue created",
"options": {
"body": "Tap to open it in Stackchain.",
"tag": "stackchain-delivery-capture-1",
"data": {"route": "#/my-work/issue/stackchain/api/44"},
},
},
{
"title": "Queued work needs attention",
"options": {
"body": "Tap to review it in Drafts.",
"tag": "stackchain-delivery-bad-1",
"data": {"route": "#/my-work/drafts"},
},
},
]
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/issue/stackchain/api/44"
]
assert result["opened"] == []
assert result["notificationClosed"] is True
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()
assert "async function sessionCsrf(signal)" in source
assert "BASE + 'api/v1/session'" in source
assert "signal," in source
assert "headers.set('X-CSRF-Token', csrf)" in source
def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
source = WORKER.read_text()
assert "async function withSessionCsrf(work)" in source
assert "batchedCsrfController = new AbortController()" in source
assert "batchedCsrf = sessionCsrf(batchedCsrfController.signal)" in source
assert "batchedCsrfController.abort()" in source
assert "await (batchedCsrf || sessionCsrf(options.signal))" in source
assert "if (options.signal?.aborted)" in source
assert "batch: withSessionCsrf" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
@@ -101,18 +322,31 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
assert set(result["added"]) == {
"/dashboard/",
"/dashboard/manifest.webmanifest",
"/dashboard/static/dashboard.css",
"/dashboard/static/dashboard.js",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-sheet.js",
@@ -120,8 +354,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/task-overlay-history.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/background-issue-sync.js",
}
@@ -157,6 +396,95 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
state.stallFetch = true;
state.cachedBody = 'cached dashboard';
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 80)),
]);
process.stdout.write(JSON.stringify({
timedOut: response === null,
body: response ? await response.text() : null,
state,
}));
"""
)
assert result["timedOut"] is False
assert result["body"] == "cached dashboard"
assert result["state"]["fetchAborted"] is True
assert result["state"]["puts"] == []
def test_stalled_navigation_without_cached_shell_returns_deterministic_504():
result = run_worker_scenario(
"""
state.stallFetch = true;
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 80)),
]);
process.stdout.write(JSON.stringify({
status: response?.status || null,
body: response ? await response.text() : null,
contentType: response?.headers.get('Content-Type') || null,
state,
}));
"""
)
assert result["status"] == 504
assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again."
assert result["contentType"] == "text/plain; charset=utf-8"
assert result["state"]["fetchAborted"] is True
def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write():
result = run_worker_scenario(
"""
state.lateFetch = true;
state.cachedBody = 'cached dashboard';
const response = await Promise.race([
dispatch('fetch', {
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
}),
new Promise(resolve => setTimeout(() => resolve(null), 35)),
]);
const body = response ? await response.text() : null;
await new Promise(resolve => setTimeout(resolve, 60));
process.stdout.write(JSON.stringify({timedOut: response === null, body, state}));
"""
)
assert result["timedOut"] is False
assert result["body"] == "cached dashboard"
assert result["state"]["fetchAborted"] is True
assert result["state"]["puts"] == []
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
result = run_worker_scenario(
"""
state.fetchRedirected = true;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate',
url: 'https://forge.example/dashboard/?title=Shared',
});
process.stdout.write(JSON.stringify({ status: response.status, state }));
"""
)
assert result["status"] == 200
assert result["state"]["puts"] == []
def test_cross_origin_navigation_is_not_intercepted_or_cached():
result = run_worker_scenario(
"""
+210
View File
@@ -0,0 +1,210 @@
import sqlite3
import pytest
from src import session_store
from src.session_store import SessionStore, SessionStoreError
def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
now = [1_000.0]
database = tmp_path / "sessions.sqlite3"
first = SessionStore(database, clock=lambda: now[0])
first.activate("first-session-secret", 2_000)
first.activate("second-session-secret", 2_000)
reconstructed = SessionStore(database, clock=lambda: now[0])
reconstructed.revoke("first-session-secret")
assert reconstructed.is_active("first-session-secret", 2_000) is False
assert reconstructed.is_active("second-session-secret", 2_000) is True
assert b"first-session-secret" not in database.read_bytes()
assert b"second-session-secret" not in database.read_bytes()
def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path):
now = [1_000.0]
database = tmp_path / "sessions.sqlite3"
first = SessionStore(database, clock=lambda: now[0])
first.activate("phone-session", 2_000)
first.activate("laptop-session", 2_000)
first.revoke_all()
reconstructed = SessionStore(database, clock=lambda: now[0])
assert reconstructed.is_active("phone-session", 2_000) is False
assert reconstructed.is_active("laptop-session", 2_000) is False
reconstructed.activate("new-session", 2_000)
assert reconstructed.is_active("new-session", 2_000) is True
def test_validation_does_not_create_a_missing_registry(tmp_path):
database = tmp_path / "sessions.sqlite3"
store = SessionStore(database, clock=lambda: 1_000.0)
with pytest.raises(SessionStoreError):
store.is_active("unknown-session", 2_000)
assert database.exists() is False
def test_validation_executes_only_a_read_query(tmp_path, monkeypatch):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("active-session", 2_000)
statements = []
connect = sqlite3.connect
def traced_connect(*args, **kwargs):
connection = connect(*args, **kwargs)
connection.set_trace_callback(statements.append)
return connection
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
assert store.is_active("active-session", 2_000) is True
assert [statement.split()[0].upper() for statement in statements] == ["SELECT"]
def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("expiring-session", 1_001)
now[0] = 1_001.0
assert store.is_active("expiring-session", 1_001) is False
with sqlite3.connect(store.path) as connection:
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
store.activate("laptop-session-secret", 3_000, device_label="Work laptop")
devices = store.list_active("phone-session-secret")
assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"]
assert [device.current for device in devices] == [False, True]
assert all(device.management_id for device in devices)
assert all(device.created_at == 1_000 for device in devices)
assert "phone-session-secret" not in repr(devices)
def test_revoke_managed_device_removes_only_the_selected_session(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone", 2_000, device_label="Phone")
store.activate("laptop", 2_000, device_label="Laptop")
phone = next(device for device in store.list_active("laptop") if device.device_label == "Phone")
assert store.revoke_managed(phone.management_id) is True
assert store.is_active("phone", 2_000) is False
assert store.is_active("laptop", 2_000) is True
assert store.revoke_managed(phone.management_id) is False
def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
)
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
store = SessionStore(database, clock=lambda: 1_000.0)
store.activate("new-session", 3_000, device_label="New phone")
assert store.is_active("existing-session", 2_000) is True
devices = store.list_active("existing-session")
assert len(devices) == 2
assert next(device for device in devices if device.current).device_label == "Existing device"
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
database = tmp_path / "sessions.sqlite3"
digest = SessionStore._digest("existing-session")
with sqlite3.connect(database) as connection:
connection.execute(
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
)
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
store = SessionStore(database, clock=lambda: 1_000.0)
grant = store.mint_step_up(
"existing-session", action="revoke_all_sessions", target="all", ttl_seconds=90
)
assert store.consume_step_up(
grant,
"existing-session",
action="revoke_all_sessions",
target="all",
) is True
def test_step_up_grant_is_single_use_and_bound_to_session_action_and_target(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 2_000)
grant = store.mint_step_up(
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
)
assert store.consume_step_up(
grant, "phone-session", action="merge_pull", target="stackchain/api#8"
) is False
assert store.consume_step_up(
grant, "other-session", action="merge_pull", target="stackchain/api#7"
) is False
assert store.consume_step_up(
grant, "phone-session", action="close_issue", target="stackchain/api#7"
) is False
assert store.consume_step_up(
grant, "phone-session", action="merge_pull", target="stackchain/api#7"
) is True
assert store.consume_step_up(
grant, "phone-session", action="merge_pull", target="stackchain/api#7"
) is False
assert grant.encode() not in store.path.read_bytes()
def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 2_000)
expired = store.mint_step_up(
"phone-session", action="close_issue", target="stackchain/api#7", ttl_seconds=90
)
revoked = store.mint_step_up(
"phone-session", action="revoke_all_sessions", target="all", ttl_seconds=90
)
now[0] = 1_091.0
assert store.consume_step_up(
expired, "phone-session", action="close_issue", target="stackchain/api#7"
) is False
now[0] = 1_010.0
store.revoke("phone-session")
assert store.consume_step_up(
revoked, "phone-session", action="revoke_all_sessions", target="all"
) is False
def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
store.activate("phone-session", 2_000, device_label="Phone")
grant = store.mint_step_up(
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
)
phone = store.list_active("phone-session")[0]
assert store.revoke_managed(phone.management_id) is True
assert store.consume_step_up(
grant,
"phone-session",
action="merge_pull",
target="stackchain/api#7",
) is False
+22 -2
View File
@@ -1,6 +1,6 @@
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
@pytest.mark.anyio
@@ -22,7 +22,9 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
html = await dashboard()
assert "fetch('api/v1/live'" in html
assert "renderLiveSnapshot(snapshot)" in html
assert "onSnapshot: renderLiveSnapshot" in html
assert "section + '_revision'" in html
assert "workChanged = contextChanged || notificationsChanged" in html
assert "renderContextSnapshot(snapshot.context)" in html
assert "paintEventStream(snapshot.events)" in html
assert "loadEventStream" not in html
@@ -55,3 +57,21 @@ async def test_dashboard_reports_each_live_section_from_its_own_freshness():
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
assert "Unread updates unavailable · showing last known updates" in html
assert "Activity refresh failed · showing last activity" in html
@pytest.mark.anyio
async def test_operator_refresh_and_reconnect_supersede_a_stalled_live_request():
html = await dashboard()
assert "async function fetchLiveSnapshot(revisions = {}, { signal } = {})" in html
assert "headers: { Accept: 'application/json' },\n signal," in html
assert "function load() { return contextPoller.refresh({ force: true }); }" in html
assert "contextPoller.refresh({ force: true });" in html
@pytest.mark.anyio
async def test_stalled_refresh_keeps_the_last_snapshot_with_specific_guidance():
html = await dashboard()
assert "e.name === 'TimeoutError'" in html
assert "Update delayed · showing last snapshot" in html
+131
View File
@@ -0,0 +1,131 @@
import json
import subprocess
from pathlib import Path
from tests.dashboard_bundle import dashboard_bundle_text
OVERLAY_HISTORY = Path(__file__).parents[1] / "frontend" / "task-overlay-history.js"
def test_task_overlay_history_unwinds_preview_search_and_dashboard_without_duplicate_entries():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const location = {{ hash:'', pathname:'/dashboard/' }};
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const changes = [];
const history = {{
get state() {{ return stack[cursor]; }},
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
back() {{ if (cursor > 0) cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
history, location,
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
controller.open('search');
controller.open('search');
controller.open('search-preview');
controller.close();
controller.close();
process.stdout.write(JSON.stringify({{changes, stack, cursor, current:controller.current()}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [
["search", None],
["search-preview", "search"],
["search", "search-preview"],
[None, "search"],
],
"stack": [
{"page": "dashboard"},
{"page": "dashboard", "taskOverlay": "search"},
{"page": "dashboard", "taskOverlay": "search-preview"},
],
"cursor": 0,
"current": None,
}
def test_task_overlay_history_rejects_unknown_states_and_preserves_existing_history_state():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const pushed = [];
const history = {{
state: {{ workRoute:'#/my-work/issue/stackchain/api/7' }},
pushState(state) {{ this.state = state; pushed.push(state); }}, back() {{}},
}};
const controller = createTaskOverlayHistory({{
history, location:{{}}, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
const invalid = controller.open('settings');
const valid = controller.open('new');
process.stdout.write(JSON.stringify({{invalid, valid, pushed}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"invalid": False,
"valid": True,
"pushed": [{
"workRoute": "#/my-work/issue/stackchain/api/7",
"taskOverlay": "new",
}],
}
def test_task_overlay_history_can_leave_an_overlay_for_a_work_route_transition():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const changes = [];
const history = {{
state: {{ page:'dashboard' }},
pushState(state) {{ this.state = state; }},
replaceState(state) {{ this.state = state; }},
back() {{ throw new Error('route transitions must not race history.back'); }},
}};
const controller = createTaskOverlayHistory({{
history, eventTarget:{{addEventListener() {{}}}},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.open('find');
const left = controller.leave();
process.stdout.write(JSON.stringify({{left, state:history.state, current:controller.current(), changes}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"left": True,
"state": {"page": "dashboard"},
"current": None,
"changes": [["find", None], [None, "find"]],
}
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
html = dashboard_bundle_text()
assert '<script src="static/task-overlay-history.js"></script>' in html
assert "createTaskOverlayHistory({" in html
assert "taskOverlayHistory.open('new')" in html
assert "taskOverlayHistory.open('find')" in html
assert "taskOverlayHistory.open('search')" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "taskOverlayHistory.close()" in html
assert "saveIssueCaptureDraft();" in html
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more