Compare commits

...

140 Commits

Author SHA1 Message Date
f9d602f85f Merge pull request 'Pin the current item from mobile detail' (#1490) from timmy/1489-pin-current-mobile-detail into main
Some checks failed
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 1h14m26s
CI / release-candidate (push) Has been cancelled
2026-08-28 02:11:46 +00:00
2a06cf1daa feat: pin current mobile detail item (Closes #1489)
Some checks failed
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 1h10m38s
CI / release-candidate (pull_request) Has been cancelled
2026-08-27 23:30:06 +00:00
b0dbcb5a25 Merge pull request 'Compact and rank pinned mobile work by recent use' (#1488) from timmy/1487-compact-rank-pinned-mobile-work into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 6s
2026-08-27 22:46:49 +00:00
2766a75f24 Compact and rank pinned mobile work
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 22:34:37 +00:00
5f9b5b3a7f Merge pull request 'Make mobile Recent Work sync generation-safe and self-healing' (#1486) from timmy/1485-mobile-recent-work-sync into main
All checks were successful
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m49s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:55:39 +00:00
cec5b93665 fix: make mobile recent work sync generation-safe (Closes #1485)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 8m1s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 21:42:47 +00:00
5af34508b4 Merge pull request 'feat: Stabilize Today-to-Week dialog closure in packaged browser CI' (#1484) from timmy/1483-stabilize-today-to-week-dialog-closure-in-packag into main
All checks were successful
CI / lint (push) Successful in 3m50s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m38s
CI / release-candidate (push) Successful in 7s
2026-08-27 21:15:15 +00:00
9b1abf37d0 fix: keep reschedule dialog closed after successful move
All checks were successful
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:48:02 +00:00
b0b28a936c Merge pull request 'Require fresh authorization and audit CI job reruns' (#1482)
Some checks failed
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Failing after 7m52s
CI / release-candidate (push) Has been skipped
2026-08-27 20:20:50 +00:00
f23be99ab4 security: fresh-authorize CI job reruns (Closes #1481)
All checks were successful
CI / lint (pull_request) Successful in 4m0s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 20:08:15 +00:00
1484c579f9 Merge pull request 'feat: Disk usage 86%' (#1480) from timmy/1479-disk-usage-86 into main
All checks were successful
CI / lint (push) Successful in 3m55s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m47s
CI / release-candidate (push) Successful in 8s
2026-08-27 19:42:11 +00:00
afec9b11d1 ops: add disk capacity incident assessment
All checks were successful
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m50s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 19:14:25 +00:00
ca37d8a1bb Merge pull request 'Pin frequent work across signed-in mobile devices' (#1478) from timmy/1477-pin-mobile-frequent-work into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m23s
CI / release-candidate (push) Successful in 8s
2026-08-27 18:46:56 +00:00
94bd2cafba feat: pin frequent mobile work (Closes #1477)
All checks were successful
CI / lint (pull_request) Successful in 4m1s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m46s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 18:34:01 +00:00
2b630d3fa8 Merge pull request 'Sync recent work across signed-in mobile devices' (#1476) from timmy/1475-sync-mobile-recent-work into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
2026-08-27 17:45:02 +00:00
19732ea5a6 feat: sync mobile recent work across devices (Closes #1475)
All checks were successful
CI / lint (pull_request) Successful in 3m47s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 17:32:46 +00:00
12a7a3c4b0 Merge pull request 'feat: Restore bare-environment full-suite cache-policy test' (#1474) from timmy/1473-restore-bare-environment-full-suite-cache-policy into main
All checks were successful
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
2026-08-27 16:49:42 +00:00
454e2dcffc test: restore cache-policy suite setup (Closes #1473)
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 7m33s
2026-08-27 16:16:52 +00:00
0e70fb9b62 Merge pull request 'Reopen recent work from mobile Queues' (#1472) from timmy/1471-mobile-recent-work into main
All checks were successful
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m32s
CI / release-candidate (push) Successful in 7s
2026-08-27 15:53:08 +00:00
eb101bf5ce feat: reopen recent work from mobile queues (Closes #1471)
All checks were successful
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 15:40:41 +00:00
46e06c1a37 Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469) from timmy/1468-demand-load-mobile-workspace into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m30s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469)
2026-08-27 14:46:10 +00:00
02b9fdd2e4 fix: finish identity recovery before workspace readiness
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 14:34:13 +00:00
5ccf499b1a test: isolate workspace retry from service worker
Some checks failed
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 13:10:30 +00:00
e8b2f31e5e fix: restore hydrated session state before action replay
Some checks failed
CI / lint (pull_request) Successful in 3m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:27:44 +00:00
c1d719842f test: await hydrated state in reload journeys
Some checks failed
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 6m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:25:22 +00:00
4a99f54f12 test: hydrate demand workspace after release reloads
Some checks failed
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:22:42 +00:00
8909bedd78 test: align packaged journeys with demand hydration
Some checks failed
CI / lint (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:17:20 +00:00
5d9cec1dd7 perf: demand-load mobile workspace features (Closes #1468)
Some checks failed
CI / lint (pull_request) Successful in 3m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 9m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 11:47:09 +00:00
2a44cf20fc Merge pull request 'Make mobile queue priority self-healing across devices' (#1467) from timmy/1466-self-healing-queue-priority into main
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m31s
CI / release-candidate (push) Successful in 7s
2026-08-27 10:43:43 +00:00
1e8d27c18f feat: make mobile queue priority self-healing (Closes #1466)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 10:32:02 +00:00
c5b801460d Merge pull request 'Sync personalized mobile queue priority across devices' (#1465)
All checks were successful
CI / lint (push) Successful in 3m27s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
Closes #1464
2026-08-27 09:39:51 +00:00
b18aa5ed63 feat: sync mobile queue priority across devices (Closes #1464)
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m11s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 09:27:27 +00:00
dbc305108a Merge pull request 'Scope Security activity to the signed-in Gitea identity' (#1463) from timmy/1462-principal-scoped-security-activity into main
All checks were successful
CI / lint (push) Successful in 3m33s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
2026-08-27 08:22:10 +00:00
3b9f184a47 security: scope activity journal by principal (Closes #1462)
All checks were successful
CI / lint (pull_request) Successful in 3m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 08:10:27 +00:00
bf716cab6e Merge pull request 'Personalize routine priority in adaptive mobile Queues' (#1461) from timmy/1460-personalized-mobile-queue-priority into main
All checks were successful
CI / lint (push) Successful in 3m41s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m34s
CI / release-candidate (push) Successful in 6s
2026-08-27 07:21:38 +00:00
dbabd8640b feat: personalize adaptive mobile queue priority (Closes #1460)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 07:09:06 +00:00
890fd57531 Merge pull request 'Keep adaptive mobile Work actionable while offline' (#1459) from timmy/1458-connectivity-aware-mobile-work into main
Some checks failed
CI / lint (push) Successful in 3m56s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m45s
CI / release-candidate (push) Has been skipped
2026-08-27 06:11:57 +00:00
b0b051184b feat: keep adaptive mobile Work actionable offline (Closes #1458)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 05:59:13 +00:00
2432d4fc03 Merge pull request 'Make Following and My PRs actionable from adaptive mobile Work' (#1457) from timmy/1456-actionable-following-authored into main
All checks were successful
CI / lint (push) Successful in 3m39s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m33s
CI / release-candidate (push) Successful in 8s
2026-08-27 04:46:58 +00:00
c97383fccb feat: continue Following and My PRs from mobile Work (Closes #1456)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m35s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 04:34:34 +00:00
89b1ad1f08 Merge pull request 'Scope Security Center device revocation to the signed-in identity' (#1455) from timmy/1454-principal-scoped-device-revocation into main
All checks were successful
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m20s
CI / release-candidate (push) Successful in 7s
Scope Security Center device revocation to the signed-in identity (#1455)
2026-08-27 03:21:57 +00:00
0328e197ac security: scope device revocation by principal (Closes #1454)
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 03:09:59 +00:00
7a09452bef Make mobile Queues census include Following and My PRs (#1453)
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m16s
CI / release-candidate (push) Successful in 8s
Closes #1452
2026-08-27 02:14:24 +00:00
4b4637fadb feat: include Following and My PRs in queue census (Closes #1452)
All checks were successful
CI / lint (pull_request) Successful in 3m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 02:02:02 +00:00
977493907e Merge pull request 'Give mobile Queues one adaptive Start / Continue action' (#1451) from timmy/1450-adaptive-mobile-queue-action into main
All checks were successful
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m16s
CI / release-candidate (push) Successful in 7s
2026-08-27 01:24:49 +00:00
9381ff9d84 feat: unify mobile queue start and continue action (Closes #1450)
All checks were successful
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 01:12:38 +00:00
c2b7ce67d8 Bind enrolled passkeys to the upstream Gitea identity (#1449)
All checks were successful
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m4s
CI / release-candidate (push) Successful in 6s
Closes #1448
2026-08-27 00:25:09 +00:00
43b0216bda security: bind passkeys to upstream identity (Closes #1448)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 00:12:57 +00:00
ff469d96d1 Recover Human Gate decisions after interrupted mobile responses
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m10s
CI / release-candidate (push) Successful in 7s
Closes #1446
2026-08-26 23:05:32 +00:00
94c18c7a35 test: classify deferred offline context rejection
All checks were successful
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m15s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:53:44 +00:00
a77a73a269 test: stabilize Human Gate recovery journey
Some checks failed
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:25:00 +00:00
b303683ba1 feat: recover interrupted Human Gate decisions (Closes #1446)
Some checks failed
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 22:10:01 +00:00
60f140d26a Merge pull request 'Paginate durable Human Gate decision history on mobile' (#1445) from timmy/1444-paginated-human-gate-history into main
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m30s
CI / release-candidate (push) Successful in 8s
2026-08-26 21:23:05 +00:00
0f1cac0e25 feat: paginate mobile Human Gate history (Closes #1444)
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m21s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 21:10:10 +00:00
57c7373079 Merge pull request 'Review durable Human Gate decision history and receipts' (#1442) from timmy/1441-human-gate-decision-history into main
All checks were successful
CI / lint (push) Successful in 3m43s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 7m6s
CI / release-candidate (push) Successful in 7s
2026-08-26 20:01:02 +00:00
a5d05d7590 feat: review durable Human Gate decision history (Closes #1441)
All checks were successful
CI / lint (pull_request) Successful in 4m43s
CI / build-release (pull_request) Successful in 13s
CI / browser-journey (pull_request) Successful in 7m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 19:43:50 +00:00
efa01cf47f Merge pull request 'Keep Human Gate release decisions visible and actionable on mobile' (#1440)
All checks were successful
CI / lint (push) Successful in 4m19s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m51s
CI / release-candidate (push) Successful in 8s
Closes #1439
2026-08-26 18:38:56 +00:00
62f92105ec feat: keep Human Gate decisions in reach on mobile (Closes #1439)
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 8m4s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 18:10:22 +00:00
38206b90e4 Merge pull request 'Notify operators when Human Gate decisions are waiting' (#1438) from timmy/1437-human-gate-push-notifications into main
All checks were successful
CI / lint (push) Successful in 4m15s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 8m11s
CI / release-candidate (push) Successful in 9s
2026-08-26 17:15:26 +00:00
de3c68fc0e docs: explain private Human Gate alerts
All checks were successful
CI / lint (pull_request) Successful in 4m21s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m42s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 17:02:54 +00:00
7158e00b97 feat: notify pending Human Gate decisions (Closes #1437)
Some checks failed
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m57s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 16:47:57 +00:00
ad15f15e3c Merge pull request 'Resume Human Gate evidence reviews after mobile artifact handoffs' (#1436) from timmy/1435-resume-human-gate-review into main
All checks were successful
CI / lint (push) Successful in 4m14s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m40s
CI / release-candidate (push) Successful in 8s
Merge pull request #1436: resume Human Gate evidence reviews
2026-08-26 15:29:27 +00:00
aba217f89b feat: resume Human Gate evidence reviews (Closes #1435)
All checks were successful
CI / lint (pull_request) Successful in 4m12s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 15:16:03 +00:00
1e9c3444fa Merge pull request 'Refresh Human Gates atomically on every reopen' (#1434) from timmy/1433-refresh-human-gates-reopen into main
All checks were successful
CI / lint (push) Successful in 4m10s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 8m15s
CI / release-candidate (push) Successful in 9s
2026-08-26 14:03:30 +00:00
2e9d108c6f feat: refresh Human Gates review sessions (Closes #1433)
All checks were successful
CI / lint (pull_request) Successful in 3m58s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 13:50:11 +00:00
7dc32138e4 Merge pull request 'Recover progressive mobile identity after partial live snapshots' (#1432) from timmy/1431-recover-progressive-mobile-identity into main
All checks were successful
CI / lint (push) Successful in 3m58s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 7m40s
CI / release-candidate (push) Successful in 9s
2026-08-26 12:55:55 +00:00
26288d364c feat: recover progressive identity after partial snapshots
All checks were successful
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m24s
CI / release-candidate (pull_request) Has been skipped
Closes #1431
2026-08-26 12:42:47 +00:00
97a36ea22f Merge pull request 'Preserve healthy live sections during partial upstream timeouts' (#1430) from timmy/1429-section-isolated-live-snapshot-deadlines into main
All checks were successful
CI / lint (push) Successful in 3m59s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m19s
CI / release-candidate (push) Successful in 8s
2026-08-26 11:55:23 +00:00
dc9e64e98e feat: isolate live snapshot section deadlines (Closes #1429)
All checks were successful
CI / lint (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 11:27:35 +00:00
065cc46553 Merge pull request 'Make mobile Work and Queues usable before optional hydration' (#1428) from timmy/1427-progressive-mobile-dock into main
All checks were successful
CI / lint (push) Successful in 3m51s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m12s
CI / release-candidate (push) Successful in 7s
Merge pull request #1428: progressive mobile dock
2026-08-26 10:40:10 +00:00
664317212d test: gate progressive dock browser journey
All checks were successful
CI / lint (pull_request) Successful in 3m59s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 10:28:27 +00:00
62de2e6011 feat: make mobile dock progressive (Closes #1427)
Some checks failed
CI / lint (pull_request) Failing after 4m5s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-26 10:18:44 +00:00
edd653eaaf Merge pull request 'Share one cold-launch live snapshot across progressive Human Gates and My Work' (#1426) from timmy/1425-shared-progressive-live-snapshot into main
All checks were successful
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 7s
2026-08-26 09:31:18 +00:00
15d7bc9234 feat: share progressive live snapshot (Closes #1425)
All checks were successful
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m54s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 08:55:08 +00:00
bea0e0e429 Merge pull request 'Adaptive mobile Queues next-up flow' (#1424) from timmy/1423-adaptive-mobile-queues into main
All checks were successful
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m57s
CI / release-candidate (push) Successful in 8s
2026-08-26 07:43:07 +00:00
b7d496600e test: exercise collapsed all queues
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m37s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 07:32:16 +00:00
8b2fd305ca feat: prioritize adaptive mobile queues
Some checks failed
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 9m7s
CI / release-candidate (pull_request) Has been skipped
Closes #1423
2026-08-26 07:12:27 +00:00
1e8d9a27be Merge pull request 'Fresh-authorize and audit Human Gate decisions' (#1422)
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 8m50s
CI / release-candidate (push) Successful in 8s
Closes #1421
2026-08-26 06:22:50 +00:00
1c66905b4a feat: fresh-authorize and audit Human Gate decisions
All checks were successful
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 06:11:28 +00:00
4319c161cb Merge pull request 'Open Human Gates before optional workspace hydration' (#1420) from timmy/1419-open-human-gates-before-optional-hydration into main
All checks were successful
CI / lint (push) Successful in 4m22s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m21s
CI / release-candidate (push) Successful in 9s
2026-08-26 05:05:08 +00:00
5ec4dd7f6e feat: open Human Gates before optional hydration
All checks were successful
CI / lint (pull_request) Successful in 4m13s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m51s
CI / release-candidate (pull_request) Has been skipped
Closes #1419
2026-08-26 04:52:14 +00:00
718f0d1209 feat: surface Human Gates in mobile preparation (#1418)
Some checks failed
CI / lint (push) Successful in 4m11s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 8m3s
CI / release-candidate (push) Has been skipped
Closes #1417
2026-08-26 03:42:45 +00:00
e9d257e203 Merge pull request 'feat: Human Gates inbox: one hash-bound review queue to zero' (#1416) from timmy/1415-human-gates-inbox-one-hash-bound-review-queue-to into main
All checks were successful
CI / lint (push) Successful in 4m13s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m54s
CI / release-candidate (push) Successful in 8s
2026-08-26 02:09:24 +00:00
af16f987f1 fix: restore timed break after identity hydration
All checks were successful
CI / lint (pull_request) Successful in 4m19s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 7m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:56:33 +00:00
f4d6f9176c docs: record Human Gates identity invariants
Some checks failed
CI / lint (pull_request) Successful in 3m55s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:30:39 +00:00
ab329aa126 fix: isolate overlapping Human Gate loads
Some checks failed
CI / lint (pull_request) Successful in 4m23s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 01:04:25 +00:00
9d84d5c5c4 fix: close Human Gates review races
All checks were successful
CI / lint (pull_request) Successful in 4m9s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:56:52 +00:00
dbb8ff4d4e fix: harden Human Gates review flow
All checks were successful
CI / lint (pull_request) Successful in 3m53s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:45:40 +00:00
364fc60116 test: show Human Gate project in review detail
All checks were successful
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 7m2s
2026-08-26 00:10:27 +00:00
e02115bd71 Add durable Human Gates inbox 2026-08-26 00:05:27 +00:00
7ec78ac182 Merge pull request 'Open progressive mobile My Work in-app before hydration' (#1414) from timmy/1413-progressive-my-work-detail into main
All checks were successful
CI / lint (push) Successful in 3m53s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 7s
2026-08-25 23:43:17 +00:00
94728d81ff feat: open progressive My Work in app (Closes #1413)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 23:31:34 +00:00
7960f14b5b Merge pull request 'Keep progressive mobile My Work live through delayed hydration' (#1412) from timmy/1411-progressive-my-work-live-recovery into main
All checks were successful
CI / lint (push) Successful in 3m50s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m23s
CI / release-candidate (push) Successful in 7s
2026-08-25 22:44:51 +00:00
f9b29d8d14 feat: keep progressive My Work live (Closes #1411)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 22:30:14 +00:00
e7e8c24d05 Recover progressive mobile captures after reload (#1410)
All checks were successful
CI / lint (push) Successful in 4m18s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 8m13s
CI / release-candidate (push) Successful in 11s
Closes #1409
2026-08-25 21:41:01 +00:00
8b56618e62 feat: recover progressive mobile captures (Closes #1409)
All checks were successful
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 21:29:50 +00:00
dcad1da1da Merge pull request 'Enable mobile capture before optional workspace hydration' (#1408) from timmy/1407-progressive-mobile-capture into main
All checks were successful
CI / lint (push) Successful in 3m35s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m39s
CI / release-candidate (push) Successful in 7s
2026-08-25 20:37:00 +00:00
8bb8fb0cd9 feat: enable progressive mobile capture
All checks were successful
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m26s
CI / release-candidate (pull_request) Has been skipped
Closes #1407
2026-08-25 20:11:13 +00:00
a23276f0b8 Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406) from timmy/1405-progressive-live-snapshot-handoff into main
All checks were successful
CI / lint (push) Successful in 3m42s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m39s
CI / release-candidate (push) Successful in 8s
Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406)
2026-08-25 19:25:21 +00:00
54cb078d1a perf: reuse progressive live snapshot during hydration (Closes #1405)
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 19:13:23 +00:00
1822004f3d Merge pull request 'Preserve mobile My Work queue truth through hydration' (#1404) from timmy/1403-progressive-queue-handoff into main
Some checks failed
CI / lint (push) Successful in 3m32s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m21s
CI / release-candidate (push) Has been skipped
2026-08-25 18:14:32 +00:00
c15f7768e4 feat: preserve progressive My Work queue truth (Closes #1403)
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 10s
CI / browser-journey (pull_request) Successful in 6m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 18:01:12 +00:00
024087d051 Merge pull request 'Hydrate mobile My Work before optional tools' (#1402) from timmy/1401-progressive-my-work-hydration into main
All checks were successful
CI / lint (push) Successful in 3m32s
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 8s
2026-08-25 16:56:49 +00:00
297ae7550a feat: hydrate mobile My Work before optional tools
All checks were successful
CI / lint (pull_request) Successful in 3m33s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m40s
CI / release-candidate (pull_request) Has been skipped
Closes #1401
2026-08-25 16:41:27 +00:00
e44f49708a Merge pull request 'Fix 320px active-Today week reschedule completion' (#1400) from timmy/1398-320-active-today-week-reschedule-dialog into main
All checks were successful
CI / lint (push) Successful in 3m35s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m45s
CI / release-candidate (push) Successful in 7s
2026-08-25 15:34:32 +00:00
3e7d758782 test: cover reschedule retry recovery
All checks were successful
CI / lint (pull_request) Successful in 3m26s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m43s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 15:12:47 +00:00
185f3f9706 fix: close mobile week reschedule while saving
Some checks failed
CI / lint (pull_request) Successful in 3m28s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m3s
CI / release-candidate (pull_request) Has been skipped
Closes #1398
2026-08-25 14:55:54 +00:00
aed278629b Merge pull request 'feat: Find photo-only reply drafts from mobile My Work' (#1399) from timmy/1396-find-photo-only-reply-drafts-from-mobile-my-work into main
All checks were successful
CI / lint (push) Successful in 3m30s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m26s
CI / release-candidate (push) Successful in 6s
2026-08-25 14:12:39 +00:00
d273de3506 fix: preserve completed mobile onboarding during draft sync
All checks were successful
CI / lint (pull_request) Successful in 3m21s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 13:35:51 +00:00
fa876e1e38 feat: surface photo-only reply drafts in My Work (Closes #1396)
Some checks failed
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 6m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 13:17:23 +00:00
306d6e3056 Merge pull request 'feat: CI regression: active timed break hidden after corrupt Today recovery on main 553a012' (#1395) from timmy/1394-ci-regression-active-timed-break-hidden-after-co into main
All checks were successful
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m25s
CI / release-candidate (push) Successful in 6s
2026-08-25 12:22:32 +00:00
978351dff2 fix: preserve active break over reload checkpoint (Closes #1394)
All checks were successful
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 11:56:50 +00:00
553a01217a Merge pull request 'Schedule mobile notification quiet hours with one catch-up digest' (#1393) from timmy/1392-mobile-notification-quiet-hours into main
Some checks failed
CI / lint (push) Successful in 3m47s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 6m30s
CI / release-candidate (push) Has been skipped
2026-08-25 11:28:45 +00:00
cc74b7b8db feat: schedule mobile notification quiet hours (Closes #1392)
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 11:17:40 +00:00
2acd9431e0 Merge pull request 'Keep mobile Search and Preview focus-contained through Back navigation' (#1391) from timmy/1390-mobile-search-modal-focus into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 8s
2026-08-25 10:20:10 +00:00
4855bc7c8d feat: contain mobile Search focus through Preview (Closes #1390)
All checks were successful
CI / lint (pull_request) Successful in 3m42s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m32s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 10:08:55 +00:00
6e19639b19 Merge pull request 'Make Search and Following conversations actionable on mobile' (#1389) from timmy/1388-search-following-comment-actions into main
All checks were successful
CI / lint (push) Successful in 3m32s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 7s
2026-08-25 09:22:04 +00:00
7662cca9e6 feat: act on Search and Following comments (Closes #1388)
All checks were successful
CI / lint (pull_request) Successful in 3m36s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 6m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 09:11:14 +00:00
0682bbcc2c Merge pull request 'Apply reviewer suggestions across files atomically' (#1387) from timmy/1386-atomic-multi-file-review-suggestions into main
All checks were successful
CI / lint (push) Successful in 3m34s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m21s
CI / release-candidate (push) Successful in 7s
2026-08-25 08:12:56 +00:00
1c02dcb217 feat: apply review suggestions across files
All checks were successful
CI / lint (pull_request) Successful in 3m32s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m13s
CI / release-candidate (pull_request) Has been skipped
Closes #1386
2026-08-25 08:02:16 +00:00
7bb22e3525 Merge pull request 'Apply same-file review suggestions as one atomic mobile fix' (#1385) from timmy/1384-atomic-review-suggestion-batch into main
All checks were successful
CI / lint (push) Successful in 3m40s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m9s
CI / release-candidate (push) Successful in 7s
2026-08-25 07:26:00 +00:00
869543e992 feat: batch mobile review suggestions (Closes #1384)
All checks were successful
CI / lint (pull_request) Successful in 3m38s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 6m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 07:14:54 +00:00
c8ffde7d96 Merge pull request 'Apply reviewer code suggestions from mobile' (#1383) from timmy/1382-mobile-review-suggestions into main
All checks were successful
CI / lint (push) Successful in 3m22s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 7s
2026-08-25 06:19:21 +00:00
ed662c3a3a feat: apply mobile review suggestions (Closes #1382)
All checks were successful
CI / lint (pull_request) Successful in 3m40s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m59s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 06:08:22 +00:00
8a881419db Merge pull request 'React to conversation comments without leaving mobile work' (#1381) from timmy/1380-mobile-comment-reactions into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m3s
CI / release-candidate (push) Successful in 7s
2026-08-25 05:21:13 +00:00
b4f3c6d419 feat: react to mobile conversation comments (Closes #1380)
All checks were successful
CI / lint (pull_request) Successful in 3m46s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 6m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 05:09:32 +00:00
fc7a83fbd7 Merge pull request 'Pin release pipeline actions and enforce least-privilege permissions' (#1379) from timmy/1378-pin-release-actions into main
All checks were successful
CI / lint (push) Successful in 3m5s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
2026-08-25 04:21:41 +00:00
22fe5dd1d8 ci: pin release pipeline actions (Closes #1378)
All checks were successful
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 24s
CI / browser-journey (pull_request) Successful in 5m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:57:29 +00:00
9d97b2e84a Merge pull request 'Commit bounded review feedback fixes from mobile' (#1377) from timmy/1376-mobile-review-feedback-fix into main
All checks were successful
CI / lint (push) Successful in 3m10s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m29s
CI / release-candidate (push) Successful in 6s
2026-08-25 03:17:59 +00:00
502fcb30f5 feat: commit review feedback fixes from mobile (Closes #1376)
All checks were successful
CI / lint (pull_request) Successful in 3m3s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m18s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 03:08:53 +00:00
b541c707fc Merge pull request 'Bind background push delivery to upstream operator identity' (#1375) from timmy/1374-bind-push-session-identity into main
All checks were successful
CI / lint (push) Successful in 3m17s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m40s
CI / release-candidate (push) Successful in 6s
2026-08-25 02:16:17 +00:00
b40a44fc45 fix: bind background push to operator identity (Closes #1374)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 5m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 02:06:25 +00:00
d1adbe72d6 Merge pull request 'Bind operator sessions to upstream Gitea identity' (#1373) from timmy/1372-bind-sessions-upstream-identity into main
All checks were successful
CI / lint (push) Successful in 3m21s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m48s
CI / release-candidate (push) Successful in 7s
2026-08-25 01:33:53 +00:00
d6e6cf2e67 feat: bind operator sessions to upstream identity (Closes #1372)
All checks were successful
CI / lint (pull_request) Successful in 3m24s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m49s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 01:12:42 +00:00
53706f18ab Merge pull request 'Prepare conflict-safe rollback pull requests from failed releases' (#1371) from timmy/1370-conflict-safe-release-rollback into main
All checks were successful
CI / lint (push) Successful in 3m20s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 5m31s
CI / release-candidate (push) Successful in 6s
2026-08-25 00:31:40 +00:00
f0be3a44a6 ci: retry transient mobile navigation journey
All checks were successful
CI / lint (pull_request) Successful in 3m15s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 5m40s
CI / release-candidate (pull_request) Has been skipped
2026-08-25 00:21:58 +00:00
b753cdd922 feat: prepare conflict-safe release rollback pulls (Closes #1370)
Some checks failed
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 6m7s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 23:55:33 +00:00
400534f446 Merge pull request 'Complete secure source-branch deletion in operator mode' (#1369) from timmy/1368-secure-source-branch-deletion into main
All checks were successful
CI / lint (push) Successful in 3m15s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m13s
CI / release-candidate (push) Successful in 6s
Closes #1368
2026-08-24 22:40:22 +00:00
a43ecac94b fix: authorize secure source branch deletion (Closes #1368)
All checks were successful
CI / lint (pull_request) Successful in 3m18s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 5m20s
CI / release-candidate (pull_request) Has been skipped
2026-08-24 22:30:56 +00:00
d6993c40fb Merge pull request 'Recover failed post-merge checks from mobile release receipts' (#1367) from timmy/1366-post-merge-release-check-recovery into main
All checks were successful
CI / lint (push) Successful in 3m14s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 5m18s
CI / release-candidate (push) Successful in 7s
2026-08-24 21:45:51 +00:00
134 changed files with 15489 additions and 568 deletions

View File

@ -7,12 +7,15 @@ on:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- run: pip install -r requirements.txt
- run: pip install -r requirements-audit.txt
@ -23,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build deterministic release bundle
run: |
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
@ -33,7 +36,7 @@ jobs:
--commit "$GITHUB_SHA" \
--source-date-epoch "$SOURCE_DATE_EPOCH"
- name: Upload tested release bundle
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: release-bundle
path: dist/
@ -44,11 +47,11 @@ jobs:
env:
STACKCHAIN_RUN_RELEASE_E2E: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with: { python-version: "3.11" }
- name: Download assembled release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist
@ -57,7 +60,7 @@ jobs:
pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_search_week_plan.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_tomorrow_conflict_release.py tests/e2e/test_mobile_week_ahead_release.py tests/e2e/test_mobile_today_week_reschedule_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py tests/e2e/test_mobile_following_release.py tests/e2e/test_mobile_detail_watch_release.py tests/e2e/test_mobile_pull_reviewer_status_release.py tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q tests/e2e/test_mobile_address_review_feedback_release.py tests/e2e/test_mobile_cancel_pull_review_request_release.py tests/e2e/test_mobile_authored_pull_queue_release.py tests/e2e/test_mobile_close_authored_pull_release.py tests/e2e/test_mobile_search_authored_pull_recovery_release.py tests/e2e/test_mobile_source_branch_cleanup_release.py
run: python3 -m pytest tests/e2e -q
release-candidate:
runs-on: ubuntu-latest
@ -66,9 +69,9 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Download tested release bundle
uses: actions/download-artifact@v3
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
name: release-bundle
path: dist

View File

@ -18,7 +18,20 @@ token that can read dashboard data, update the authenticated user's notification
threads, create and self-assign issues, discover, claim, and release issue assignments,
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
requests, merge assigned pull requests, and submit pull-request reviews. For authored
pulls, up to eight non-overlapping one-line reviewer suggestions across bounded UTF-8
files can be staged during the mobile feedback pass, reviewed in a path-grouped manifest,
and committed as one atomic branch update. The batch preserves every original blob
identity, applies same-file replacements from the bottom up, rejects overlapping or stale
lines, and verifies the advanced pull head and every final file before reporting success.
The server uses Gitea's multi-file contents mutation so a cross-file implementation-and-test
fix cannot be partially committed.
After an exact merged commit fails release checks, its mobile release receipt can load the
failed job evidence and prepare a draft rollback pull request. The server revalidates the
operator's participation and push access, reverses only bounded UTF-8 files whose current
default-branch content still matches the failed merge, and creates one atomic commit on a
stable operator-owned branch. Conflicts, renames, binary files, and oversized changes fail
closed; retrying converges on the same draft PR, which opens in the existing review workspace.
Assigned-issue, assigned-pull-request, and unread-update conversation composers accept an ordered bundle of up to five PNG, JPEG, or WebP photos. Repeated camera captures append to the bundle, the gallery picker accepts multiple images, and each composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary. Before sending, the selected conversation photo can use the same touch editor as New issue evidence to crop, privacy-redact, highlight, or add an arrow; Apply replaces only that flattened derivative while preserving its caption and bundle position, and Cancel leaves the original unchanged.
For online delivery, every photo uploads before the comment is posted to the exact conversation target, producing one ordered Markdown comment or reply; validation or upload failures keep the typed text and removable preview available for retry. Offline photo conversations admit every image Blob to IndexedDB before confirmation, keep only bounded metadata in localStorage, and checkpoint each upload separately so reconnect resumes at the first unconfirmed photo without duplicating an upload, comment, reply, or reply-and-read transition. The mobile **New issue** capture-first stage accepts an ordered evidence bundle of up to
five PNG, JPEG, or WebP screenshots before a repository is chosen, optimizing each image independently
@ -108,6 +121,27 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path.
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
Mobile **Recent work** is also portable across signed-in devices. Opening an issue, pull request,
review, Filed item, or update records its canonical detail route locally before navigation and marks
the entry **Sync pending** until the authenticated API confirms it. Reconnect and foreground checks
merge the server list without duplicate routes, while each confirmed account remains bounded to its
five most recent items. A separate **Pin** action keeps up to 20 frequently revisited items above
Recent work even after that five-item window advances; **Unpin** removes only the pin, and both actions
apply offline-first before account-scoped synchronization. Open and Pin/Unpin remain separate touch and
keyboard targets. Titles, repositories, routes, and pins are encrypted at rest with the shared
private-state key; stale responses from a prior account are discarded. Set
`STACKCHAIN_RECENT_WORK_DB` to override `.stackchain-state/recent-work.sqlite3`.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
The detail control loads authoritative Gitea state, remains single-flight while changing it, and refreshes Following only
@ -287,7 +321,7 @@ each envelope to its operation key and field purpose so rows and fields cannot b
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
likewise migrate on first read. Synchronized Saved Search collections and completed Filed review
likewise migrate on first read. Synchronized Saved Search collections, mobile Recent work, and completed Filed review
receipts use the private-state key and authenticate each envelope to its normalized account, preventing
rows from being substituted between operators. Existing plaintext Saved Searches migrate atomically on
first read without advancing their revision; existing completed Filed receipts migrate transactionally at
@ -838,3 +872,23 @@ Checkpoint replacement uses writer-unique, flushed temporary files and an
atomic rename, preventing concurrent writers from colliding or exposing partial
JSON. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
## Human Gates inbox
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
binds each queue to the upstream principal ID and login, including its offline
browser cache. Review decisions carry `expected_revision`, a stable idempotency
key across network retries and mobile app restarts, and return durable receipts.
An interrupted decision response is restored as **Decision outcome unknown**;
the operator verifies the exact persisted operation instead of creating a second
decision. The client clears that operation only after recovering a receipt. If producer evidence
changes after a release or hold, the update reopens the exact hash for a new
revision-checked decision instead of silently retaining the old outcome.
New hashes mark older pending candidates `superseded` without removing their audit
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
and Telegram notifications expose count and route only, using
`#/my-work/human-gates`; they never expose project, title, candidate hash,
artifacts, checks, provenance, reasons, or receipts.

45
docs/human-gates.md Normal file
View File

@ -0,0 +1,45 @@
# Human Gates producer and notification contract
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
A cold open of the canonical route confirms the account and opens Human Gates from the core browser runtime while optional Today and Planning bundles continue hydrating or recovering. The full workspace adopts that controller and fixed review snapshot without a second queue request or duplicate decision handlers.
## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
```json
{
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": "abc123",
"title": "Dashboard candidate",
"priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": "success", "required": true}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"}
}
```
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
Before a mobile client submits Release or Hold, it stores one account-, gate-, and revision-bound pending operation with the exact payload and idempotency key. A transport interruption changes the decision tray to **Decision outcome unknown** and blocks replacement decisions. **Verify decision** replays that exact operation; server-side idempotency returns the original receipt if the decision committed, or executes it once if the first request never arrived. The pending operation and saved review progress are removed only after a receipt is confirmed. Reloading or restarting the installed app restores this verification flow even when the committed gate is no longer present in the live pending queue. Pending decision data never crosses the immutable account cache boundary.
## Privacy-safe notification contract
Web Push is opt-in per authenticated device under **My Work → Settings → Notify me when release decisions are waiting**. The preference is stored with that device's push subscription; revoked sessions are removed before delivery. The poller honors the device's routine-alert quiet hours, coalesces unchanged pending counts, and routes a notification tap to `#/my-work/human-gates`.
Web Push, Telegram, and other lock-screen adapters MUST expose **count and route only**:
```json
{
"pending_count": 3,
"route": "#/my-work/human-gates"
}
```
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.

View File

@ -1,4 +1,8 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const reactionLabels = {
'+1': 'Thumbs up', '-1': 'Thumbs down', laugh: 'Laugh', hooray: 'Hooray',
confused: 'Confused', heart: 'Heart', rocket: 'Rocket', eyes: 'Eyes',
};
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
@ -14,6 +18,12 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
'/comments/' + encodeURIComponent(commentId);
}
function reactionPath(context, commentId, content = '') {
return pathFor(context, commentId) + '/reactions' +
(content ? '/' + encodeURIComponent(content) : '');
}
const pendingReactions = new Set();
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
@ -41,15 +51,98 @@ function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false
pager.remove(commentId);
return pager.snapshot();
},
async loadReactions(context, commentId) {
return fetchJson(reactionPath(context, commentId), {
headers: { Accept: 'application/json' },
});
},
async setReaction(context, commentId, content, active) {
return fetchJson(reactionPath(context, commentId, content), {
method: 'PUT',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ active: Boolean(active) }),
});
},
reactionHtml(state) {
const current = new Map((state?.reactions || []).map(item => [item.content, item]));
return Object.entries(reactionLabels).map(([content, label]) => {
const item = current.get(content) || {};
const count = Number.isInteger(item.count) && item.count > 0 ? item.count : 0;
const selected = item.selected === true;
return '<button type="button" data-comment-reaction="' + content + '" ' +
'aria-pressed="' + selected + '" aria-label="' + label + ' ' + count + '">' +
label + ' ' + count + '</button>';
}).join('') + '<button type="button" data-comment-reactions-close>Cancel</button>';
},
actionHtml(comment) {
return controller.isOwned(comment) ?
const owned = controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
return owned + '<div class="comment-reactions" aria-label="Comment reactions">' +
'<button type="button" data-comment-reactions-open aria-expanded="false" ' +
'aria-label="React to this comment">React</button>' +
'<div class="comment-reaction-menu" data-comment-reaction-menu hidden></div></div>';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const button = event.target.closest('[data-comment-action]');
const actionButton = event.target.closest('[data-comment-action]');
const reactionClose = actionButton ? null : event.target.closest('[data-comment-reactions-close]');
const reactionButton = actionButton ? null : event.target.closest('[data-comment-reaction]');
const reactionTrigger = actionButton ? null : event.target.closest('[data-comment-reactions-open]');
if (reactionClose) {
const card = reactionClose.closest('.issue-comment');
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (menu && trigger) {
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
return;
}
if (reactionButton || reactionTrigger) {
const control = reactionButton || reactionTrigger;
const card = control.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const menu = card?.querySelector('[data-comment-reaction-menu]');
const trigger = card?.querySelector('[data-comment-reactions-open]');
if (!commentId || !menu || !trigger) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to react.';
return;
}
if (pendingReactions.has(commentId)) return;
pendingReactions.add(commentId);
control.disabled = true;
try {
if (reactionButton) {
const content = reactionButton.dataset.commentReaction;
const active = reactionButton.getAttribute('aria-pressed') !== 'true';
const state = await controller.setReaction(surface.context, commentId, content, active);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
surface.status.textContent = 'Reaction updated.';
} else {
surface.status.textContent = 'Loading reactions…';
const state = await controller.loadReactions(surface.context, commentId);
menu.innerHTML = controller.reactionHtml(state);
menu.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
surface.status.textContent = '';
}
} catch (error) {
surface.status.textContent = error.message || 'Reactions are unavailable. Please retry.';
} finally {
pendingReactions.delete(commentId);
control.disabled = false;
}
return;
}
const button = actionButton;
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);

View File

@ -44,9 +44,14 @@ function createContextPoller({
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
const degradedRetrySeconds = (sectionValues || [])
.filter(section => section.degraded)
.map(section => Number(section.retry_in_seconds ?? freshness.retry_in_seconds))
.filter(seconds => Number.isFinite(seconds) && seconds > 0);
const failedSectionDelay = degradedRetrySeconds.length ?
Math.min(...degradedRetrySeconds) * 1000 : null;
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
return failedSectionDelay || intervalMs;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
@ -55,10 +60,10 @@ function createContextPoller({
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
const healthyDelay = earliestDeadline <= 0 &&
sectionValues.some(section => !section.degraded && section.revalidating) ?
intervalMs : Math.max(0, earliestDeadline) * 1000;
return failedSectionDelay === null ? healthyDelay : Math.min(healthyDelay, failedSectionDelay);
}
return intervalMs;
}
@ -121,7 +126,7 @@ function createContextPoller({
let request;
try {
request = fetchContext({ ...revisions }, { signal: controller.signal });
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
@ -166,9 +171,47 @@ function createContextPoller({
return refresh({ force: true });
}
function adopt(snapshot) {
if (
stopped || activeRequest || retainedSnapshot || !snapshot ||
typeof snapshot !== 'object' ||
!Object.prototype.hasOwnProperty.call(snapshot, 'context')
) return false;
retainedSnapshot = { ...snapshot };
revisions = { ...(snapshot.revisions || {}) };
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
section => Object.prototype.hasOwnProperty.call(snapshot, section)
);
onSnapshot(retainedSnapshot, changedSections);
schedule(nextDelayMs);
return true;
}
async function adoptPending(snapshotPromise) {
if (!snapshotPromise || typeof snapshotPromise.then !== 'function') return false;
let deadline = null;
const timeout = new Promise(resolve => {
deadline = setDeadlineTimer(() => resolve(null), timeoutMs);
});
try {
const snapshot = await Promise.race([
Promise.resolve(snapshotPromise).catch(() => null),
timeout,
]);
return adopt(snapshot);
} finally {
clearDeadlineTimer(deadline);
}
}
return {
start: refresh,
refresh,
adopt,
adoptPending,
setVisible,
getState() {
return {

View File

@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createConversationPhotoDrafts({ store, lanes }) {
return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) {
const states = {};
Object.entries(lanes || {}).forEach(([kind, lane]) => {
states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() };
@ -24,7 +24,11 @@
const attachments = await current.controller.serialize();
const save = current.pending.then(() => store.save(target, attachments));
current.pending = save.catch(() => null);
try { return await save; }
try {
const saved = await save;
onChange();
return saved;
}
catch (error) { current.onError?.(error); throw error; }
}
@ -67,6 +71,7 @@
const target = { ...current.target };
await current.pending;
await store.remove(target);
onChange();
current.restoring = true;
try { current.controller.clear(); }
finally {

View File

@ -33,10 +33,11 @@
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
if (operation === 'list') return requestResult(records.getAll());
return requestResult(records.get(key));
};
}
@ -97,7 +98,10 @@
const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target);
const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment);
if (!list.length) { await transact('delete', id); return null; }
await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list });
await transact('put', id, {
id, version:1, ownerLogin, scope:recordScope, ...normalized,
updatedAt:Date.now(), attachments:list,
});
return list;
}
@ -122,6 +126,38 @@
return true;
}
return { save, load, remove };
async function list() {
if (!transact || draftScope !== 'conversation') return [];
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) return [];
const records = await transact('list');
return (Array.isArray(records) ? records : []).flatMap(record => {
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
(record.scope && record.scope !== 'conversation') ||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
const updatedAt = Number(record.updatedAt || 0);
if (record.kind === 'update') {
const notificationId = Number(record.notificationId || 0);
if (!Number.isInteger(notificationId) || notificationId < 1) return [];
return [{
id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId,
photo_count:record.attachments.length, updated_at:updatedAt,
route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation',
}];
}
const repository = String(record.repository || '');
const number = Number(record.number || 0);
if (!['issue', 'pull'].includes(record.kind) || !repository ||
!Number.isInteger(number) || number < 1) return [];
const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos';
return [{
id:record.id, kind:'photo-reply', label, repository, number,
title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt,
route:{ kind:record.kind, repository, number }, photo_store:'conversation',
}];
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
}
return { save, load, remove, list };
};
});

View File

@ -81,4 +81,37 @@ function createConversationPager({ loadPage }) {
};
}
function renderConversationComment(comment, controller, escapeHtml, formatTime, renderMarkdown) {
const actions = controller?.actionHtml?.(comment) || '';
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function createConversationRenderers({qs,renderComment,updateReadPosition,getSelectedUpdate}) {
function status(kind, comments, total) {
qs('#' + kind + '-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function paint(kind, state, controller) {
const comments = state?.comments || [];
qs('#' + kind + '-comments').innerHTML = comments.length ? comments.map(comment =>
kind === 'pull' ? '<div class="pull-comment-card">' + renderComment(comment,controller) + '</div>' :
renderComment(comment,controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-' + kind + '-comments').hidden = !Number.isInteger(state?.older_page);
status(kind,comments,state?.total);
if (kind === 'update') {
const newest = qs('#update-comments .issue-comment:last-child');
updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest);
}
}
return {
paintIssueConversation:(state,controller)=>paint('issue',state,controller),
paintPullConversation:(state,controller)=>paint('pull',state,controller),
paintUpdateConversation:(state,controller)=>paint('update',state,controller),
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;

View File

@ -146,6 +146,12 @@ button:hover { filter: brightness(1.15); }
.issue-comment { min-width:0; overflow-wrap:anywhere; }
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
.comment-owned-actions button { min-height:44px; min-width:72px; }
.comment-reactions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; min-width:0; }
.comment-reactions > button { min-height:44px; min-width:72px; }
.comment-reaction-menu { display:flex; gap:8px; flex-wrap:wrap; width:100%; min-width:0; }
.comment-reaction-menu[hidden] { display:none; }
.comment-reaction-menu button { min-height:44px; min-width:88px; flex:1 1 104px; overflow-wrap:anywhere; }
.comment-reaction-menu button[aria-pressed="true"] { border-color:#4ade80; background:#123c2b; color:#dcfce7; }
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
@ -258,6 +264,9 @@ textarea { resize: vertical; min-height: 120px; }
.app-badge-control { min-height:44px; }
.push-update-control { min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.push-quiet-hours-times { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; width:100%; }
.push-quiet-hours-times label { min-width:0; }
.offline-work-controls .push-quiet-hours-times input { box-sizing:border-box; width:100%; min-width:0; height:44px; }
.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; }
@ -271,6 +280,15 @@ textarea { resize: vertical; min-height: 120px; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.progressive-work-detail { position:fixed; inset:0; z-index:120; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.progressive-work-detail[hidden] { display:none; }
.progressive-work-detail-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:calc(18px + env(safe-area-inset-top)) 18px calc(18px + env(safe-area-inset-bottom)); border:1px solid #55d6be; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
.progressive-work-detail-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.progressive-work-detail-panel h2, .progressive-work-detail-panel p { margin-top:0; }
.progressive-work-detail-panel button, .progressive-work-detail-panel .button-link { box-sizing:border-box; min-height:44px; }
.progressive-work-detail-panel .button-link { display:flex; align-items:center; justify-content:center; width:100%; }
@media(max-width:320px) { .progressive-work-detail-panel { width:100%; padding-inline:14px; } }
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
.plan-today-sheet[hidden] { display:none; }
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
@ -759,6 +777,21 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
.pull-feedback-code-actions, .pull-feedback-file-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.pull-feedback-code-actions button, .pull-feedback-file-actions button { min-height:44px; }
.pull-feedback-suggestion-preview { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #31577f; border-radius:10px; background:#07101e; }
.pull-feedback-suggestion-preview[hidden] { display:none; }
.pull-feedback-suggestion-preview h5 { margin:0; }
.pull-feedback-suggestion-change { display:grid; gap:8px; min-width:0; }
.pull-feedback-suggestion-change > div { min-width:0; }
.pull-feedback-suggestion-change pre { box-sizing:border-box; max-width:100%; max-height:160px; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word; }
.pull-feedback-suggestion-preview input { box-sizing:border-box; width:100%; min-height:44px; }
.pull-feedback-batch-review { min-width:0; display:grid; gap:10px; margin:10px 0; padding:10px; border:1px solid #3b82f6; border-radius:10px; background:#07101e; }
.pull-feedback-batch-review[hidden] { display:none; }
.pull-feedback-batch-review h5 { margin:0; }
.pull-feedback-batch-review ul { min-width:0; margin:0; padding-left:20px; overflow-wrap:anywhere; }
.pull-feedback-batch-review input { box-sizing:border-box; width:100%; min-width:0; min-height:44px; }
#review-pull-feedback-batch { position:sticky; bottom:calc(8px + env(safe-area-inset-bottom)); z-index:4; width:100%; min-height:44px; }
.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; }
@ -855,8 +888,11 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.current-work-pin { display:none; }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav { display:none; }
@media (max-width:600px) {
[data-current-work-pin] { display:inline-flex; align-items:center; justify-content:center; min-width:64px; min-height:44px; padding-inline:12px; }
[data-current-work-pin="unpin"] { border-color:#60a5fa; background:#17365a; color:#fff; }
.issue-sheet-panel, .pull-sheet-panel, .update-sheet-panel, .review-sheet-panel { padding-top:max(12px,env(safe-area-inset-top)); }
.mobile-issue-detail-nav, .mobile-pull-detail-nav, .mobile-update-detail-nav, .mobile-review-detail-nav {
position:sticky; top:env(safe-area-inset-top); z-index:6;
@ -1179,6 +1215,12 @@ textarea { resize: vertical; min-height: 120px; }
.voice-conversation-review textarea { box-sizing:border-box; width:100%; min-height:96px; resize:vertical; }
.create-issue-capture-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; }
.create-issue-capture-actions[hidden] { display:none; }
.create-issue-sheet.progressive-capture .mobile-create-issue-nav,
.create-issue-sheet.progressive-capture .create-issue-attachment,
.create-issue-sheet.progressive-capture .create-issue-filing,
.create-issue-sheet.progressive-capture #switch-to-create-pull,
.create-issue-sheet.progressive-capture .today-capture-interruption,
.create-issue-sheet.progressive-capture .shared-content-conflict { display:none; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
@ -1310,6 +1352,14 @@ textarea { resize: vertical; min-height: 120px; }
.pull-feedback-pass-heading h4 { margin:2px 0 8px; }
.pull-feedback-pass p { white-space:pre-wrap; }
.pull-feedback-pass textarea { width:100%; min-height:88px; box-sizing:border-box; }
#make-pull-feedback-fix { width:100%; margin-top:8px; border-color:#4ade80; }
.pull-feedback-file-editor { display:grid; min-width:0; gap:8px; margin-top:10px; padding:10px; border:1px solid #4ade80; border-radius:10px; overflow-x:hidden; }
.pull-feedback-file-editor[hidden] { display:none; }
.pull-feedback-file-editor h5 { margin:0; overflow-wrap:anywhere; }
#pull-feedback-file-content { min-height:36dvh; resize:vertical; font:13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size:2; white-space:pre; overflow:auto; }
#pull-feedback-commit-message { box-sizing:border-box; width:100%; min-width:0; }
.pull-feedback-file-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; min-width:0; }
@media (max-width:359px) { .pull-feedback-file-actions { grid-template-columns:1fr; } }
.pull-feedback-dispositions { display:grid; grid-template-columns:1fr; gap:6px; margin:10px 0; padding:8px; }
.pull-feedback-dispositions button[aria-pressed="true"] { border-color:#60a5fa; background:#173b63; }
.pull-feedback-navigation { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px; }
@ -1449,7 +1499,7 @@ textarea { resize: vertical; min-height: 120px; }
.composer-keyboard-active .review-inline-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-queue-sheet { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.following-sheet { width:100%; max-width:none; border:0; border-radius:18px 18px 0 0; }
.mobile-first-task { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
@ -1462,9 +1512,32 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-first-task-actions button:disabled { opacity:.55; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel { box-sizing:border-box; max-height:100dvh; overflow-y:auto; overscroll-behavior:contain; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-queue-next { display:grid; gap:6px; margin-top:12px; padding:12px; border:1px solid #60a5fa; border-radius:14px; background:#122f50; }
.mobile-queue-next p, .mobile-queue-group h3 { margin:0; }
.mobile-queue-next button { min-height:48px; width:100%; text-align:center; font-weight:800; }
.mobile-queue-priority { margin-top:12px; border:1px solid #31577f; border-radius:12px; background:#0b1b30; }
.mobile-queue-priority > summary { min-height:44px; display:flex; align-items:center; padding:0 12px; cursor:pointer; font-weight:700; }
.mobile-queue-priority > p { margin:0; padding:0 12px 10px; }
.mobile-queue-priority-list { display:grid; gap:6px; padding:0 8px; }
.mobile-queue-priority-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; min-width:0; padding:6px 4px; border-top:1px solid #233f61; }
.mobile-queue-priority-row > span:first-child { min-width:0; overflow-wrap:anywhere; font-weight:700; }
.mobile-queue-priority-controls { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:4px; }
.mobile-queue-priority-controls button { min-height:44px; min-width:64px; padding:6px 8px; }
.mobile-queue-priority-footer { display:grid; gap:6px; padding:10px 12px 12px; }
.mobile-queue-priority-footer button { min-height:44px; }
.mobile-queue-priority-conflict { margin:0 8px 10px; padding:10px; border:1px solid #f59e0b; border-radius:10px; background:#3b2808; }
.mobile-queue-priority-conflict p { margin:0 0 8px; }
.mobile-queue-priority-conflict button { min-height:44px; margin:4px 4px 0 0; }
.mobile-queue-group { margin-top:16px; }
.mobile-queue-group h3 { font-size:1rem; }
.mobile-queue-all { margin-top:16px; }
.mobile-queue-all summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.mobile-queue-list [data-unavailable="true"] { border-color:#f59e0b; }
.mobile-queue-list [data-unavailable="true"]::after { content:'Sync unavailable · open to retry'; color:#fbbf24; font-size:.75rem; }
.mobile-queue-list [data-progressive-loading="true"]::after { content:'Still loading · available after workspace starts'; }
.mobile-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
@ -1476,12 +1549,16 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
.mobile-start-day h3, .mobile-start-day p { margin:0; }
.mobile-start-day p + p { margin-top:4px; }
.mobile-start-day-action { width:100%; min-height:48px; text-align:center; }
.mobile-start-day-finish { min-height:44px; width:100%; background:transparent; }
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.mobile-recent-work-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; min-width:0; }
.mobile-queue-list .mobile-recent-work-row button { min-height:44px; width:auto; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-route] { min-width:0; width:100%; min-height:56px; overflow-wrap:anywhere; }
.mobile-queue-list .mobile-recent-work-row [data-recent-work-pin] { min-width:64px; justify-content:center; padding-inline:12px; }
.mobile-pinned-work-toggle { min-height:44px; width:100%; margin-top:8px; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
@ -1565,3 +1642,10 @@ textarea { resize: vertical; min-height: 120px; }
.create-pull-mode label{display:flex;align-items:center;gap:7px}
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-decision-tray{display:grid;gap:10px}.human-gate-decision-state{display:grid;gap:4px}.human-gate-decision-state [data-gate-error]{color:var(--danger,#fb7185)}.human-gate-decision-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gate-decision-actions button{min-height:44px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
.human-gate-views{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:760px;margin:0 auto 14px}.human-gate-views button{min-height:44px}.human-gate-views button[aria-pressed="true"]{border-color:var(--accent);background:rgba(96,165,250,.14)}.human-gate-history-card time{font-size:.78rem;color:var(--muted)}.human-gate-history-more{min-height:44px;width:100%;padding:max(10px,env(safe-area-inset-bottom)) 12px}.human-gate-state{font-weight:700}.human-gate-state-released{color:#86efac}.human-gate-state-held{color:#fbbf24}.human-gate-state-superseded{color:#cbd5e1}.human-gate-receipt{padding-bottom:16px}
.human-gate-decision-recovery{border-color:#f59e0b}.human-gate-decision-recovery .human-gate-decision-state span{color:var(--muted)}.human-gate-decision-recovery button{min-height:44px;min-width:140px}
@media(max-width:600px){.human-gate-detail{padding-bottom:calc(124px + env(safe-area-inset-bottom))}.human-gate-decision-tray{position:sticky;bottom:calc(-1 * max(24px,env(safe-area-inset-bottom)));z-index:4;margin:0 -16px calc(-124px - env(safe-area-inset-bottom));padding:12px 16px;padding-bottom:max(16px,env(safe-area-inset-bottom));border-top:1px solid var(--border);background:rgba(11,21,38,.97);box-shadow:0 -12px 24px rgba(0,0,0,.32);backdrop-filter:blur(10px)}}
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}

View File

@ -1,5 +1,12 @@
(async function(){
const workspaceLifecycle = await loadWorkspace({ document, window });
const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window }));
await workspaceLifecycle.optionalReady;
const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();
const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();
const progressiveHumanGatesHandoff = window.stackchainProgressiveHumanGates?.handoff?.();
const progressiveMobileDockHandoff = window.stackchainProgressiveMobileDock?.handoff?.();
window.stackchainProgressiveMobileDock?.stop?.();
window.stackchainProgressiveMyWork?.stop();
const qs = (s, el=document) => el.querySelector(s);
const announceWork = message => qs('#my-work-action-status').textContent = message;
const fmt = (d) => new Date(d).toLocaleString();
@ -105,6 +112,10 @@
}
let queueCounts = {};
let preparationItems = {};
let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {};
let mobileQueuePriority = null;
let mobileRecentWork = { record:() => false, render:() => 0 };
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
@ -114,11 +125,15 @@
queueCounts.following = count;
queueCounts.followingUnavailable = false;
preparationItems.following = items.filter(item => item.has_unseen_change === true);
renderMobileQueuePresentation();
mobileTaskDock.updateQueues(queueCounts);
mobileStartDay.render();
},
onStatus:status => {
if (status === 'loading') return;
queueCounts.followingUnavailable = status === 'error';
renderMobileQueuePresentation();
mobileTaskDock.updateQueues(queueCounts);
mobileStartDay.render();
},
onReviewComplete:() => mobileStartDay.completePhase('following'),
@ -159,6 +174,7 @@
}
const mobileQueueLauncher = createMobileQueueLauncher({
openDelivery: () => mobileDeliveryRecovery.open(),
openHumanGates: () => openHumanGates(),
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
@ -168,8 +184,25 @@
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: announceWork,
getCounts: () => queueCounts,
isOnline: () => !offlineWorkMode,
getRoutineOrder: () => mobileQueuePriority?.getOrder(),
getPreparation: () => {
const briefing = mobileStartDay.briefing();
return {...briefing, active:mobileStartDay.state().active};
},
openPreparation: () => mobileStartDay.startNext(),
openFindWork: () => qs('#find-work').click(),
rows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
nextAction: qs('#mobile-queue-next-action'),
activeList: qs('#mobile-queue-active-list'),
planningList: qs('#mobile-queue-planning-list'),
allList: qs('#mobile-queue-all-list'),
activeSection: qs('#mobile-queue-active-list').parentElement,
});
renderMobileQueuePresentation = () => mobileQueueLauncher.renderPresentation();
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
function openMobileStartDay() {
followingQueue.load().catch(() => {});
const state = mobileStartDay.state();
@ -177,7 +210,7 @@
qs('#mobile-queue-heading').textContent = state.active ? 'Resume Prepare Today' : 'Prepare Today';
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
qs('#mobile-start-day-action').focus();
qs('#mobile-queue-next-action').focus();
}
const mobileFirstTask = createMobileFirstTask({
getLogin: () => confirmedOwnerLogin,
@ -216,17 +249,20 @@
qs('#mobile-queue-heading').textContent = 'Prepare Today · ' + current.label;
const sheet = qs('#mobile-queue-sheet');
if (!sheet.open) sheet.showModal();
qs('#mobile-start-day-action').focus();
qs('#mobile-queue-next-action').focus();
},
elements: {
summary: qs('#mobile-start-day-summary'),
phases: qs('#mobile-start-day-phases'),
action: qs('#mobile-start-day-action'),
finish: qs('#finish-mobile-start-day'),
},
});
mobileStartDay.start();
qs('#finish-mobile-start-day').addEventListener('click', () => mobileStartDay.finish());
renderMobileQueuePresentation();
qs('#finish-mobile-start-day').addEventListener('click', () => {
mobileStartDay.finish();
renderMobileQueuePresentation();
});
function showMobileQueueCompletion(completedName, cleared = true, phase = '') {
if (cleared && phase && mobileStartDay.completePhase(phase)) return;
mobileStartDay.render();
@ -278,7 +314,7 @@
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => refreshTomorrowQueueSummary(),
queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
@ -287,6 +323,12 @@
},
});
mobileTaskDock.start();
if (['work', 'queues'].includes(progressiveMobileDockHandoff?.lastTask)) {
mobileTaskDock.select(progressiveMobileDockHandoff.lastTask);
}
if (progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open) {
qs('#mobile-queue-sheet').showModal();
}
const mobileTodayActions = qs('#mobile-today-actions');
createMobileTodayCommandBar({
more:qs('[data-mobile-today-more]'),
@ -317,18 +359,20 @@
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
let offlineWorkMode = false;
let initialAccountRecovery = Promise.resolve(false);
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all';
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
const hasProgressiveWorkFilter = WORK_FILTERS.includes(progressiveWorkHandoff?.selectedFilter);
let selectedWorkFilter = hasProgressiveWorkFilter ? progressiveWorkHandoff.selectedFilter : 'all';
let selectedFiledView = 'needs-review';
let selectedWorkMilestone = 'all';
let queueFindQuery = '';
let savedWorkFilter = null;
let launchFilterResolved = false;
let launchFilterResolved = hasProgressiveWorkFilter;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
if (!hasProgressiveWorkFilter && ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
selectedWorkFilter = savedFilter;
savedWorkFilter = savedFilter;
launchFilterResolved = true;
@ -386,24 +430,67 @@
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
let releaseReceipt = null;
function attachReleaseReceipt() {
if (releaseReceipt) return releaseReceipt;
releaseReceipt = createReleaseReceipt({
mobileRecentWork = createMobileRecentWork({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
fetchJson:fetchReviewJson,
document,
section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'),
pinnedSection:qs('#mobile-pinned-work'),
pinnedList:qs('#mobile-pinned-work-list'),
pinnedToggle:qs('#mobile-pinned-work-toggle'),
detailPins:qsa('[data-current-work-pin]'),
status:qs('#mobile-recent-work-status'),
openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
if (window.location.hash !== fragment) window.history.pushState({ workRoute:fragment }, '', fragment);
workRoute.sync();
},
});
mobileRecentWork.startLifecycle({window, document});
mobileQueuePriority = createMobileQueuePriority({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
fetchJson: fetchReviewJson,
document,
list: qs('#mobile-queue-priority-list'),
resetButton: qs('#reset-mobile-queue-priority'),
status: qs('#mobile-queue-priority-status'),
conflict: qs('#mobile-queue-priority-conflict'),
keepLocalButton: qs('#keep-local-mobile-queue-priority'),
useRemoteButton: qs('#use-remote-mobile-queue-priority'),
labels: {
attention:'Attention', today:'Today', update:'Updates', agenda:'Agenda',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
},
onChange: () => {
renderMobileQueuePresentation();
},
});
mobileQueuePriority.start();
mobileQueuePriority.startLifecycle({window, document});
let rR = null;
function rRC() {
if (rR) return rR;
rR = createReleaseReceipt({
storage:localStorage, getLogin:()=>confirmedOwnerLogin, fetchJson:fetchReviewJson,
launcher:qs('#release-receipt-launcher'), dialog:qs('#release-receipt-sheet'),
statusNode:qs('#release-receipt-status'), checksNode:qs('#release-receipt-checks'),
releaseNode:qs('#release-receipt-link'),
listNode:qs('#release-watchlist'),
openPull:openPullSheet,
});
releaseReceipt.bind();
rR.bind();
qs('#close-release-receipt').addEventListener('click', () => qs('#release-receipt-sheet').close());
qs('#refresh-release-receipt').addEventListener('click', () => releaseReceipt.refresh().catch(error => {
qs('#refresh-release-receipt').addEventListener('click', () => rR.refresh().catch(error => {
qs('#release-receipt-status').textContent = error.message + ' Retry when connected.';
}));
qs('#dismiss-release-receipt').addEventListener('click', () => releaseReceipt.dismiss());
return releaseReceipt;
qs('#dismiss-release-receipt').addEventListener('click', () => rR.dismiss());
return rR;
}
const completedFiledReview = createCompletedFiledReview({
storage: localStorage,
@ -618,6 +705,7 @@
if (!response.ok) {
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Review request failed.');
error.status = response.status;
error.payload = payload;
error.code = payload.detail?.code;
const retryAfter = response.headers.get('Retry-After');
@ -627,6 +715,70 @@
return payload;
}
const humanGatesOnChange = (snapshot, state)=>{
queueCounts.gate = snapshot.pending_count;
appBadge.reconcile('human-gates', snapshot.pending_count, state.authoritative === true);
queueCounts.gateUnavailable = state.available === false;
preparationItems.gate = snapshot.items;
mobileTaskDock.updateQueues(queueCounts);
if (state.authoritative) mobileStartDay.reconcile({authoritative:true, authoritativePhases:['gate']});
mobileStartDay.render();
};
const humanGates = progressiveHumanGatesHandoff?.controller || createHumanGates({
storage:localStorage,
getLogin:()=>planningOwnerLogin,
getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine,
location:window.location,
fetchJson:fetchReviewJson,
onChange:humanGatesOnChange,
nodes:{
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
status:qs('#human-gates-status'), panel:qs('#human-gates'),
detail:qs('#human-gate-detail'),
pendingTab:qs('#human-gates-pending'), historyTab:qs('#human-gates-history'),
},
});
progressiveHumanGatesHandoff?.adoptIdentity(planningOwnerLogin, planningOwnerAccountKey);
humanGates.setOnChange?.(humanGatesOnChange);
const openHumanGates = () => humanGates.open().catch(error => {
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
});
if (!progressiveHumanGatesHandoff) {
qs('#open-human-gates').addEventListener('click', openHumanGates);
qs('#close-human-gates').addEventListener('click', () => {
qs('#human-gates').hidden = true;
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
});
qs('#human-gates-list').addEventListener('click', event => {
const card = event.target.closest('[data-human-gate-id]');
if (!card) return;
humanGates.select(card.dataset.humanGateId);
});
qs('#human-gates-pending').addEventListener('click', () => humanGates.showPending());
qs('#human-gates-history').addEventListener('click', () => humanGates.showHistory().catch(error => {
qs('#human-gates-status').textContent = error.message;
}));
qs('#human-gate-detail').addEventListener('click', event => {
const recovery = event.target.closest('[data-gate-recover]');
if (recovery) {
recovery.disabled = true;
humanGates.recoverDecision().catch(error => {
qs('#human-gates-status').textContent = error.message || 'The decision outcome could not be verified.';
recovery.disabled = false;
});
return;
}
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
humanGates.submitDecision(decision).catch(error => {
if (!error?.targetSelector) qs('#human-gates-status').textContent = error.message;
});
});
}
if (!progressiveHumanGatesHandoff?.started) humanGates.load().catch(() => {});
if (window.location.hash === '#/my-work/human-gates' && !progressiveHumanGatesHandoff?.started) openHumanGates();
function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight;
@ -776,6 +928,7 @@
try {
const attachments = await searchReplyAttachmentController.serialize();
await searchReplyDraftStore.save(target, attachments);
void refreshPhotoDraftInbox();
} catch (error) {
qs('#search-preview-reply-status').textContent = error?.message ||
'Photos could not be saved. They remain in this preview; retry before leaving.';
@ -1001,6 +1154,7 @@
} });
const conversationPhotoDrafts = createConversationPhotoDrafts({
store:conversationPhotoDraftStore,
onChange:() => { void refreshPhotoDraftInbox(); },
lanes:{
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
@ -1290,7 +1444,7 @@
return false;
}, ()=>selectedPullDetail, ()=>confirmedOwnerLogin);
}
attachReleaseReceipt();
rRC();
if (!reviewController) reviewController = createReviewController({ fetchJson: fetchReviewJson, storage: localStorage });
if (!wrapPreference) {
wrapPreference = createReviewController.createWrapPreference({
@ -1306,9 +1460,13 @@
try {
if (!localStorage.getItem('stackchain.release-receipt.v1:' + account)) return;
} catch (_error) { return; }
ensurePullWorkflow().then(() => releaseReceipt.restore()).catch(() => {});
ensurePullWorkflow().then(() => rR.restore()).catch(() => {});
}
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
const photoDraftInbox = createPhotoDraftInbox({
conversation:conversationPhotoDraftStore,
search:searchReplyDraftStore,
});
outboxCoordinator.subscribe(() => refreshMyWorkView());
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
let offlineStorageReady = await offlineWorkStore.ready();
@ -1924,6 +2082,8 @@
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
await openRoutedWorkSection(item);
mobileRecentWork.record(item);
mobileRecentWork.setCurrent(item);
const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
},
@ -1954,6 +2114,7 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
mobileRecentWork.setCurrent(null);
issueVoiceReply.cancel();
pullVoiceReply.cancel();
updateVoiceReply.cancel();
@ -3400,12 +3561,17 @@
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
}));
return draftInbox.list().concat(unfiled).sort((left, right) =>
return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) =>
Number(right.updated_at || 0) - Number(left.updated_at || 0)
);
}
function refreshMyWorkView({ reconcileSession = true } = {}) {
async function refreshPhotoDraftInbox() {
try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false }); }
catch (_error) { /* Local photo inventory must not break My Work. */ }
}
function refreshMyWorkView({ reconcileSession = true, refreshFirstTask = true } = {}) {
lastDrafts = listDrafts();
const actionableMyWork = filedHistoryTabs.prepare(lastMyWork);
const partitioned = laterWork.partition(actionableMyWork, {
@ -3431,8 +3597,14 @@
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
counts.gate = queueCounts.gate;
counts.gateUnavailable = queueCounts.gateUnavailable;
counts.following = queueCounts.following;
counts.followingUnavailable = queueCounts.followingUnavailable;
preparationItems = {
delivery:draftInbox.partition(lastDrafts).deliveries,
gate:preparationItems.gate || [],
following:preparationItems.following || [],
agenda:agendaMyWork(activeMyWork),
attention:activeMyWork.filter(item => item.needs_attention),
update:activeMyWork.filter(item => item.has_update),
@ -3453,13 +3625,14 @@
if (element) element.textContent = count;
});
queueCounts = counts;
mobileQueueLauncher.renderPresentation();
mobileStartDay.reconcile({
authoritative:authoritativeMyWorkRefresh,
authoritativePhases:['delivery'],
});
mobileStartDay.render();
mobileTaskDock.updateQueues(counts);
mobileFirstTask.refresh();
if (refreshFirstTask) mobileFirstTask.refresh();
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
@ -3578,7 +3751,7 @@
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
'<span class="draft-preview">' + escapeHtml(photoDraftInbox.description(item)) + '</span>' + state + attempt +
'<span class="small">Saved ' + escapeHtml(fmt(item.updated_at)) + '</span>' +
'<div class="draft-actions">' + outboxActions + '</div></article>';
};
@ -3617,6 +3790,9 @@
await dFS.open(item.capture_id, item.ready ? button : null);
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.kind === 'photo-reply' && item.route?.kind === 'search') {
taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) });
}
else if (item.route) workRoute.open(item.route);
};
});
@ -3735,6 +3911,7 @@
}
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item?.kind === 'photo-reply') await photoDraftInbox.discard(item);
else if (item) draftInbox.discard(item.id);
lastDrafts = listDrafts();
const count = qs('[data-work-count="draft"]');
@ -4096,46 +4273,17 @@
toggle?.focus();
}
function renderIssueComment(comment, controller = commentActions) {
const actions = controller?.actionHtml?.(comment) || '';
return '<div class="issue-comment" data-comment-id="' + Number(comment.id || 0) + '"><div class="small">' +
escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(fmt(comment.created_at)) : '') +
'</div>' + actions + '<div class="issue-sheet-content markdown-content">' +
renderMarkdown(comment.body || 'No comment body provided.') + '</div></div>';
}
function paintIssueConversation(state, controller) {
const comments = state?.comments || [];
qs('#issue-comments').innerHTML = comments.length ?
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-issue-comments').hidden = !Number.isInteger(state?.older_page);
qs('#issue-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
function paintUpdateConversation(state, controller) {
const comments = state?.comments || [];
qs('#update-comments').innerHTML = comments.length ?
comments.map(comment => renderIssueComment(comment, controller)).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-update-comments').hidden = !Number.isInteger(state?.older_page);
qs('#update-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
const newest = qs('#update-comments .issue-comment:last-child');
updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest);
}
function paintPullConversation(state, controller) {
const comments = state?.comments || [];
qs('#pull-comments').innerHTML = comments.length ? comments.map(comment =>
'<div class="pull-comment-card">' + renderIssueComment(comment, controller) + '</div>'
).join('') : '<div class="muted">No comments yet.</div>';
qs('#load-older-pull-comments').hidden = !Number.isInteger(state?.older_page);
qs('#pull-conversation-status').textContent = comments.length ?
comments.length + ' of ' + Math.max(state.total || 0, comments.length) + ' messages loaded.' : 'No comments yet.';
}
const {paintIssueConversation,paintPullConversation,paintUpdateConversation}=createConversationRenderers({
qs,renderComment:(comment,controller)=>renderConversationComment(
comment,controller,escapeHtml,fmt,renderMarkdown),
updateReadPosition,getSelectedUpdate:()=>selectedUpdate,
});
function commentSurface(selector) {
if (selector === '#search-preview-comments') return {
context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}, pager:searchPreview.commentPager(),
render:state=>showSearchConversationWithActions(state), status:qs('#search-preview-conversation-status'),
};
if (selector === '#issue-comments') return {
context:{kind:'issue',item:selectedIssue}, pager:issueConversation,
render:renderIssueConversation, status:qs('#issue-sheet-status'),
@ -5521,6 +5669,14 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
planningOwnerLogin = activeFlushLogin;
planningOwnerAccountKey = snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
mobileQueuePriority.render();
renderMobileQueuePresentation();
void mobileRecentWork.load();
void mobileQueuePriority.load();
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
updateDeliveryReceiptControls();
@ -5532,11 +5688,17 @@
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
timerView.render();
updatePlanningAvailability();
if (planningOwnerLogin) {
syncPendingTomorrow();
todaySync.migrate(todayWork.read());
todaySync.flush();
initialAccountRecovery = Promise.all([
initialAccountRecovery,
todaySync.flush(),
]).then(() => true);
laterSync.migrate(laterWork.read());
laterSync.flush();
}
@ -5663,8 +5825,14 @@
commandSearch.setScope(scope);
}
let searchPreviewDetail = null;
const renderSearchConversation = conversation =>
renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown);
const showSearchConversationWithActions = createSearchPreviewConversationActions({
hydrator:actionHydrator, rootNode:qs('#search-preview-comments'),
retry:qs('#retry-search-preview-comment-actions'),
paint:(conversation,controller)=>renderSearchPreviewConversation(
conversation,document,escapeHtml,fmt,renderMarkdown,controller),
wire:controller=>wireCommentActions('#search-preview-comments',controller),
});
const renderSearchConversation = conversation => { void showSearchConversationWithActions(conversation); };
function renderSearchPreview(state) {
followingQueue.preview(state);
const sheet = qs('#search-preview');
@ -5877,6 +6045,7 @@
mediaQuery: window.matchMedia('(max-width: 600px)'),
schedule: callback => requestAnimationFrame(callback),
});
const searchModal = createMobileSearchModal({document});
function closeSearchPreview(navigate = true) {
if (searchPreviewReturnKind === 'following') {
searchPreview.close();
@ -5892,7 +6061,7 @@
qs('#cmd-palette').classList.add('open');
qs('#cmd-input').setAttribute('aria-expanded', 'true');
renderCommands(qs('#cmd-input').value);
qs('#cmd-input').focus();
searchModal.transition(qs('#cmd-palette'), { initialFocus:qs('#cmd-input') });
}
const next = searchPreview.next;
async function openPreviewWorkInMyWork(detail) {
@ -5914,6 +6083,7 @@
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
searchModal.transition(qs('#search-preview'), { initialFocus:qs('#close-search-preview') });
taskOverlayHistory.open('search-preview', {
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
});
@ -5960,7 +6130,10 @@
mobileSearchViewport.open();
mobileSearchViewport.restoreScroll();
qs('#cmd-input').setAttribute('aria-expanded', 'true');
qs('#cmd-input').focus();
searchModal.activate(qs('#cmd-palette'), {
opener:document.activeElement,
initialFocus:qs('#cmd-input'),
});
commandSelection = -1;
renderCommands(qs('#cmd-input').value);
}
@ -5979,6 +6152,7 @@
else {
searchPreview.close();
mobileSearchViewport.close();
searchModal.deactivate();
}
searchPreviewReturnKind = null;
}
@ -5986,7 +6160,7 @@
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
mobileSearchViewport.close();
qs('#open-palette').focus();
searchModal.deactivate();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
@ -6014,6 +6188,10 @@
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
searchPreviewReturnKind = 'search';
searchModal.activate(qs('#search-preview'), {
opener:document.activeElement,
initialFocus:qs('#close-search-preview'),
});
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
}
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
@ -6417,6 +6595,12 @@
if (selector === '#create-issue-title') scheduleIssueDuplicateCheck();
})
);
if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) {
issueCapture.saveDraft({repository:'', labelIds:[],
title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body});
progressiveCaptureHandoff.complete?.();
await openCreateIssueSheet(false);
}
qs('#create-issue-repository').addEventListener('change', event => {
issueTemplatePicker.changeRepository(currentIssueCaptureDraft());
issueOwnerPicker.reset(event.target.value);
@ -7368,7 +7552,7 @@
qs('#pull-sheet-status').textContent = 'Merging pull request…';
try {
const mergeResult = await pullController.merge(selectedPull, selectedPullDetail.head_sha);
releaseReceipt.capture(merging, mergeResult);
rR.capture(merging, mergeResult);
closePullSheet();
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
@ -7813,6 +7997,7 @@
}
function setOfflineWorkMode(value) {
offlineWorkMode = value;
renderMobileQueuePresentation();
['#find-work', '#start-work-session', '#load-more-work', '#load-more-notifications', '#bulk-mark-read']
.forEach(selector => { const button = qs(selector); if (button) button.disabled = value; });
if (value) {
@ -7825,8 +8010,12 @@
if (!saved) return false;
const outage = mode === 'outage';
confirmedOwnerLogin = String(saved.user?.login || '').trim();
mobileQueuePriority.render();
renderMobileQueuePresentation();
restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin;
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
interruptionPrompt.restore();
updatePlanningAvailability();
syncPendingTomorrow();
@ -8315,7 +8504,8 @@
});
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) {
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
pushControllerReady = (workspaceLifecycle.serviceWorkerReady ||
navigator.serviceWorker.register('service-worker.js')).then(async () => {
await issueCaptureFeatures.load('push-notifications');
const controller = createPushNotifications({
control:qs('#push-updates'),
@ -8330,6 +8520,8 @@
startDayHour:qs('#push-start-day-hour'),
followingControl:qs('#push-following'),
followingStatus:qs('#push-following-status'),
humanGateControl:qs('#push-human-gates'),
humanGateStatus:qs('#push-human-gates-status'),
deadlineSnooze:qs('#deadline-snooze'),
deadlineSnoozeStatus:qs('#deadline-snooze-status'),
deadlineSnoozeReview:qs('#review-snoozed-deadlines'),
@ -8432,9 +8624,36 @@
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
});
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
await load();
let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);
if (!adoptedProgressiveSnapshot && progressiveWorkHandoff?.liveSnapshotPromise) {
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
}
if (!adoptedProgressiveSnapshot) await load();
else if (!confirmedOwnerLogin) {
await contextPoller.refresh({ force:true, full:true });
if (!confirmedOwnerLogin) {
try {
const identity = await fetchReviewJson('api/v1/background-identity');
const login = String(identity?.login || '').trim();
if (login) {
confirmedOwnerLogin = login;
planningOwnerLogin = login;
initialAccountRecovery = timerView.restore(todaySync.flush());
}
} catch (_error) {}
}
}
if (progressiveWorkHandoff?.openWork) {
const progressiveItem = lastMyWork.find(item =>
item.repository === progressiveWorkHandoff.openWork.repository &&
Number(item.number) === Number(progressiveWorkHandoff.openWork.number));
if (progressiveItem) {
openRoutedWork(progressiveItem, null);
qs('#progressive-work-detail').hidden = true;
}
}
await appShortcut.run();
contextPoller.start();
document.addEventListener('visibilitychange', () => {
contextPoller.setVisible(!document.hidden);
if (!document.hidden) deviceSetup?.render();
@ -8442,4 +8661,8 @@
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
await initialAccountRecovery;
if (planningOwnerLogin) await todaySync.flush();
await timerView.restore(Promise.resolve(true));
workspaceLifecycle.markWorkspaceReady?.();
})();

510
frontend/human-gates.js Normal file
View File

@ -0,0 +1,510 @@
function createHumanGates(options = {}) {
const storage = options.storage || window.localStorage;
const getLogin = options.getLogin || (() => '');
const getAccountKey = options.getAccountKey || getLogin;
const isOnline = options.isOnline || (() => navigator.onLine);
const location = options.location || window.location;
const fetchJson = options.fetchJson;
const nodes = options.nodes || {};
let queue = { pending_count: 0, items: [] };
let reviewSnapshot = [];
let reviewIndex = -1;
let loadedAccountKey = '';
let loadEpoch = 0;
const decisionKeys = new Map();
let decisionFlight = null;
let openFlight = null;
let onChange = options.onChange;
let historyItems = [];
let historyNextCursor = null;
let historyLoadedMore = false;
let historyLoadError = '';
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character]);
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const progressKey = item => 'stackchain.human-gate-review.v1:' +
String(getAccountKey() || '').trim().toLowerCase() + ':' + item.id + ':' + item.revision;
const decisionStorageKey = () => 'stackchain.human-gate-decision.v1:' +
String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
const publish = state => onChange?.(JSON.parse(JSON.stringify(queue)), state);
function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
}
function restore() {
if (!getLogin()) return null;
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
}
function save(value) {
if (!getLogin()) return;
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
}
function restoreProgress(item) {
if (!getLogin() || !item) return null;
try {
const value = JSON.parse(storage.getItem(progressKey(item)) || 'null');
if (!value || value.gate_id !== item.id || value.revision !== item.revision) return null;
return value;
} catch (_) { return null; }
}
function saveProgress(values = {}) {
const item = current();
if (!getLogin() || !item) return false;
const checklist = values.checklist || {};
const progress = {
gate_id:item.id, revision:item.revision,
checklist:Object.fromEntries(['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].map(key => [key, checklist[key] === true])),
reason:String(values.reason || ''), override_reason:String(values.override_reason || ''),
};
try { storage.setItem(progressKey(item), JSON.stringify(progress)); return true; } catch (_) { return false; }
}
function restorePendingDecision(item = null) {
if (!getLogin()) return null;
try {
const value = JSON.parse(storage.getItem(decisionStorageKey()) || 'null');
if (!value || typeof value !== 'object' || !value.idempotency_key || !value.operation ||
!value.payload || !value.gate_id || !Number.isInteger(value.revision)) return null;
if (item && (value.gate_id !== item.id || value.revision !== item.revision)) return null;
return value;
} catch (_) { return null; }
}
function savePendingDecision(value) {
storage.setItem(decisionStorageKey(), JSON.stringify(value));
}
function clearPendingDecision(item) {
if (!restorePendingDecision(item)) return;
try { storage.removeItem?.(decisionStorageKey()); } catch (_) {}
}
function valuesFromDetail() {
const checklist = Object.fromEntries(Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || []).map(input => [input.dataset.gateChecklist, input.checked]));
return {
checklist,
reason:nodes.detail?.querySelector?.('[data-gate-reason]')?.value || '',
override_reason:nodes.detail?.querySelector?.('[data-gate-override]')?.value || '',
};
}
function updateReadiness(values = valuesFromDetail()) {
const completed = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => values.checklist?.[key] === true).length;
setText(nodes.detail?.querySelector?.('[data-gate-readiness]'), completed + ' of 3 confirmations complete');
}
function captureProgress() {
if (!nodes.detail?.querySelectorAll) return false;
const values = valuesFromDetail();
updateReadiness(values);
return saveProgress(values);
}
nodes.detail?.addEventListener?.('input', captureProgress);
nodes.detail?.addEventListener?.('change', captureProgress);
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
setText(nodes.status, 'Human Gates inbox zero.');
return;
}
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
setHtml(nodes.list, queue.items.map(item =>
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
).join(''));
}
function setView(view) {
nodes.pendingTab?.setAttribute?.('aria-pressed', view === 'pending' ? 'true' : 'false');
nodes.historyTab?.setAttribute?.('aria-pressed', view === 'history' ? 'true' : 'false');
}
function renderHistory(preserveDetail = false) {
setView('history');
if (historyNextCursor) {
setText(nodes.status, historyItems.length + ' Human Gate decisions loaded. Older decisions are available.');
} else if (historyLoadedMore) {
setText(nodes.status, 'All ' + historyItems.length + ' Human Gate decisions loaded.');
} else {
setText(nodes.status, historyItems.length + (historyItems.length === 1 ? ' past Human Gate decision.' : ' past Human Gate decisions.'));
}
if (!historyItems.length) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>No decision history</strong><span>Released and held candidates will appear here.</span></div>');
setHtml(nodes.detail, '');
return;
}
setHtml(nodes.list, historyItems.map(item =>
'<button class="human-gate-card human-gate-history-card" type="button" data-human-gate-history-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><span class="human-gate-state human-gate-state-' + escape(item.state) + '">' +
escape(item.state.charAt(0).toUpperCase() + item.state.slice(1)) + '</span>' +
'<code>' + escape(item.candidate_hash) + '</code><time>' + escape(new Date(Number(item.updated_at) * 1000).toLocaleString()) + '</time></button>'
).join('') + (historyNextCursor ? '<button class="human-gate-history-more" type="button" data-human-gate-history-more>' +
(historyLoadError ? 'Retry older decisions' : 'Load older decisions') + '</button>' : ''));
Array.from(nodes.list?.querySelectorAll?.('[data-human-gate-history-id]') || []).forEach(card => {
card.addEventListener('click', () => selectHistory(card.dataset.humanGateHistoryId).catch(error => {
setText(nodes.status, error.message || 'Human Gate history is unavailable.');
}));
});
nodes.list?.querySelector?.('[data-human-gate-history-more]')?.addEventListener?.('click', () => {
loadMoreHistory().catch(error => setText(nodes.status, error.message || 'Older Human Gate decisions are unavailable.'));
});
if (!preserveDetail) setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Decision history</strong><span>Open a candidate to review its durable receipt.</span></div>');
}
async function showHistory() {
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const result = validSnapshot(await fetchJson('api/v1/human-gates?state=history&limit=20'));
if (!result) throw new Error('Human Gate history response is invalid.');
historyItems = result.items;
historyNextCursor = result.next_cursor || null;
historyLoadedMore = false;
historyLoadError = '';
renderHistory();
return JSON.parse(JSON.stringify(historyItems));
}
async function loadMoreHistory() {
if (!historyNextCursor) return JSON.parse(JSON.stringify(historyItems));
if (!isOnline()) throw new Error('Human Gate history requires an online connection.');
const cursor = historyNextCursor;
let result;
try {
result = validSnapshot(await fetchJson(
'api/v1/human-gates?state=history&limit=20&cursor=' + encodeURIComponent(cursor)
));
if (!result) throw new Error('Human Gate history response is invalid.');
} catch (error) {
historyLoadError = error?.message || 'Older Human Gate decisions are unavailable.';
renderHistory(true);
setText(nodes.status, historyLoadError + ' Loaded decisions are still available.');
throw error;
}
const known = new Set(historyItems.map(item => item.id));
historyItems.push(...result.items.filter(item => !known.has(item.id)));
historyNextCursor = result.next_cursor || null;
historyLoadedMore = true;
historyLoadError = '';
renderHistory(true);
return JSON.parse(JSON.stringify(historyItems));
}
async function selectHistory(gateId) {
const summary = historyItems.find(item => item.id === gateId);
if (!summary) throw new Error('Gate is not in the current history.');
const detail = await fetchJson('api/v1/human-gates/' + encodeURIComponent(gateId));
if (!detail || detail.id !== gateId) throw new Error('Gate history detail is invalid.');
let receipt = null;
if (detail.receipt_id) receipt = await fetchJson('api/v1/human-gate-receipts/' + encodeURIComponent(detail.receipt_id));
const checklist = receipt?.checklist || {};
const confirmations = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed']
.filter(key => checklist[key] === true).map(key => '<li>' + escape(key.replaceAll('_', ' ')) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail human-gate-receipt"><p class="small">' + escape(detail.state.toUpperCase()) + '</p>' +
'<h3>' + escape(detail.title) + '</h3><p>Project <strong>' + escape(detail.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(detail.candidate_hash) + '</code></p>' +
(receipt ? '<p>Decision receipt <code>' + escape(receipt.receipt_id) + '</code></p>' +
'<p>Decided ' + escape(new Date(Number(receipt.decided_at) * 1000).toLocaleString()) + '</p>' +
(receipt.reason ? '<h4>Reason</h4><p>' + escape(receipt.reason) + '</p>' : '') +
(receipt.override_reason ? '<h4>Override</h4><p>' + escape(receipt.override_reason) + '</p>' : '') +
(confirmations ? '<h4>Confirmed</h4><ul>' + confirmations + '</ul>' : '') :
'<p>No decision receipt exists because this candidate was superseded.</p>') + '</article>');
return {detail, receipt};
}
function showPending() {
setView('pending');
render();
renderDetail(current());
return JSON.parse(JSON.stringify(queue));
}
function renderDetail(item) {
if (!item) {
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
return;
}
const progress = restoreProgress(item) || {checklist:{}, reason:'', override_reason:''};
const pendingDecision = restorePendingDecision(item);
const checked = key => progress.checklist?.[key] === true ? ' checked' : '';
const checks = (item.checks || []).map(check =>
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
).join('');
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" target="_blank" rel="noreferrer noopener">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" target="_blank" rel="noreferrer noopener">' + escape(link.label) + '</a></li>').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
'<h4>History</h4><ul>' + history + '</ul>' +
'<label><input type="checkbox" data-gate-checklist="exact_hash"' + checked('exact_hash') + '> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"' + checked('artifacts_reviewed') + '> Artifacts reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"' + checked('provenance_reviewed') + '> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason>' + escape(progress.reason) + '</textarea></label>' +
'<label>Override reason<textarea data-gate-override>' + escape(progress.override_reason) + '</textarea></label>' +
'<div class="human-gate-decision-tray' + (pendingDecision ? ' human-gate-decision-recovery' : '') + '"><div class="human-gate-decision-state">' +
(pendingDecision ? '<strong>Decision outcome unknown</strong><span role="status">The previous ' + escape(pendingDecision.decision) +
' response was interrupted. Verify it before making another decision.</span>' :
'<strong data-gate-readiness>0 of 3 confirmations complete</strong><span data-gate-error role="alert" hidden></span>') + '</div>' +
'<div class="human-gate-decision-actions">' + (pendingDecision ?
'<button type="button" data-gate-recover>Verify decision</button>' :
'<button type="button" data-gate-decision="hold">Hold &amp; next</button><button type="button" data-gate-decision="release">Release &amp; next</button>') +
'</div></div></article>'
);
updateReadiness();
}
async function load() {
const epoch = ++loadEpoch;
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
if (accountKey !== loadedAccountKey) {
loadedAccountKey = accountKey;
queue = { pending_count: 0, items: [] };
reviewSnapshot = [];
reviewIndex = -1;
render();
}
const cached = restore();
if (cached) {
queue = cached;
render();
publish({available:true, authoritative:false, cached:true});
}
try {
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!live) throw new Error('Human Gates response is invalid.');
queue = { pending_count: live.pending_count, items: live.items.slice() };
const interrupted = restorePendingDecision();
if (interrupted && interrupted.item?.id === interrupted.gate_id && interrupted.item.revision === interrupted.revision) {
const recoveryItem = {...interrupted.item, decision_recovery:true};
const existingIndex = queue.items.findIndex(candidate => candidate.id === interrupted.gate_id);
if (existingIndex >= 0) queue.items[existingIndex] = recoveryItem;
else {
queue.items.unshift(recoveryItem);
queue.pending_count += 1;
}
}
save(queue);
render();
publish({available:true, authoritative:true});
return queue;
} catch (error) {
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!cached) {
publish({available:false, authoritative:false});
throw error;
}
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
return queue;
}
}
function reviewNext() {
if (reviewIndex < 0) {
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
}
const item = reviewSnapshot[reviewIndex] || null;
renderDetail(item);
if (item) {
const index = reviewIndex;
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
reviewSnapshot[index] = detail;
renderDetail(detail);
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
}
return item;
}
function select(gateId) {
if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
const index = reviewSnapshot.findIndex(item => item.id === gateId);
if (index < 0) throw new Error('Gate is not in the current review snapshot.');
reviewIndex = index;
return reviewNext();
}
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
function idempotencyKey(item, decision, payload) {
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
const pending = restorePendingDecision(item);
if (pending?.operation === operation) return { operation, key: pending.idempotency_key };
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
decisionKeys.set(operation, key);
return { operation, key };
}
function completeDecision(item, decision, operation, receipt) {
decisionKeys.delete(operation);
clearPendingDecision(item);
try { storage.removeItem?.(progressKey(item)); } catch (_) {}
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
publish({available:true, authoritative:true, decision:true});
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
}
async function postDecision(item, pending) {
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': pending.idempotency_key },
body: JSON.stringify(pending.payload),
});
return completeDecision(item, pending.decision, pending.operation, receipt);
}
function decisionError(message, selector) {
const error = new Error(message);
error.targetSelector = selector;
return error;
}
async function decideAndNext(decision, values = {}) {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const checklist = values.checklist || {};
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
if (decision === 'release' && !complete) {
const firstMissing = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].find(key => checklist[key] !== true);
throw decisionError('Complete the release checklist before deciding.', '[data-gate-checklist="' + firstMissing + '"]');
}
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
const names = unmet.map(check => String(check.name || 'Unnamed check')).join(', ');
throw decisionError('An explicit override reason is required for unmet required checks: ' + names + '.', '[data-gate-override]');
}
if (decision === 'hold' && !String(values.reason || '').trim()) {
throw decisionError('A hold reason is required.', '[data-gate-reason]');
}
const payload = {
expected_revision: item.revision, decision,
reason: String(values.reason || '').trim(),
override_reason: String(values.override_reason || '').trim(), checklist,
};
if (decisionFlight) return decisionFlight;
const operation = (async () => {
const decisionKey = idempotencyKey(item, decision, payload);
const pending = {
gate_id:item.id, revision:item.revision, decision, payload,
operation:decisionKey.operation, idempotency_key:decisionKey.key,
item:JSON.parse(JSON.stringify(item)),
};
savePendingDecision(pending);
try {
return await postDecision(item, pending);
} catch (error) {
renderDetail(item);
setText(nodes.status, 'Decision outcome unknown. Verify the interrupted decision while online.');
throw error;
}
})();
decisionFlight = operation;
try {
return await operation;
} finally {
if (decisionFlight === operation) decisionFlight = null;
}
}
async function recoverDecision() {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Decision verification requires an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const pending = restorePendingDecision(item);
if (!pending) throw new Error('No interrupted decision exists for this gate revision.');
if (decisionFlight) return decisionFlight;
const operation = postDecision(item, pending);
decisionFlight = operation;
try { return await operation; }
finally { if (decisionFlight === operation) decisionFlight = null; }
}
async function submitDecision(decision) {
const values = valuesFromDetail();
updateReadiness(values);
const errorNode = nodes.detail?.querySelector?.('[data-gate-error]');
if (errorNode) { errorNode.textContent = ''; errorNode.hidden = true; }
const buttons = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-decision]') || []);
buttons.forEach(button => { button.disabled = true; });
try {
return await decideAndNext(decision, values);
} catch (error) {
if (errorNode) {
errorNode.textContent = error?.message || 'The decision could not be saved.';
errorNode.hidden = false;
}
let target = error?.targetSelector && nodes.detail?.querySelector?.(error.targetSelector);
if (!target && error?.targetSelector?.startsWith('[data-gate-checklist=')) {
const key = error.targetSelector.match(/"([^"]+)"/)?.[1];
target = Array.from(nodes.detail?.querySelectorAll?.('[data-gate-checklist]') || [])
.find(input => input.dataset.gateChecklist === key);
}
target?.scrollIntoView?.({block:'center', behavior:'smooth'});
target?.focus?.({preventScroll:true});
throw error;
} finally {
buttons.forEach(button => { button.disabled = false; });
}
}
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
setView('pending');
await load();
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
return reviewNext();
})();
openFlight = operation;
const clearFlight = () => {
if (openFlight === operation) openFlight = null;
};
operation.then(clearFlight, clearFlight);
return operation;
}
return {
load, open, reviewNext, select, showHistory, loadMoreHistory, selectHistory, showPending,
decideAndNext, recoverDecision, submitDecision, current, saveProgress,
setOnChange(callback) { onChange = callback; },
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash,
};
}
if (typeof module !== 'undefined') module.exports = createHumanGates;
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;

View File

@ -187,7 +187,20 @@
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
<button class="human-gates-launcher" id="open-human-gates" type="button">Review next <span id="human-gates-count">0</span></button>
</div>
<section id="human-gates" class="human-gates" aria-labelledby="human-gates-heading" hidden>
<div class="human-gates-header">
<div><h3 id="human-gates-heading">Human Gates</h3><p id="human-gates-status" class="small" role="status" aria-live="polite"></p></div>
<button id="close-human-gates" type="button">Close</button>
</div>
<div class="human-gate-views" role="group" aria-label="Human Gate view">
<button id="human-gates-pending" type="button" aria-pressed="true">Pending</button>
<button id="human-gates-history" type="button" aria-pressed="false">History</button>
</div>
<div id="human-gates-list" class="human-gates-list"></div>
<div id="human-gate-detail" class="human-gate-detail-host"></div>
</section>
<details class="work-settings">
<summary id="work-settings-toggle">Queue &amp; settings · <span id="active-work-queue">All</span></summary>
<div class="work-settings-panel">
@ -224,6 +237,14 @@
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
<label class="push-update-control" for="push-following"><input id="push-following" type="checkbox" /> Notify me when Following changes</label>
<span class="small" id="push-following-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-human-gates"><input id="push-human-gates" type="checkbox" /> Notify me when release decisions are waiting</label>
<span class="small" id="push-human-gates-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-quiet-hours"><input id="push-quiet-hours" type="checkbox" /> Pause routine alerts on a schedule</label>
<div class="push-quiet-hours-times">
<label for="push-quiet-start">From <input id="push-quiet-start" type="time" value="22:00" /></label>
<label for="push-quiet-end">Until <input id="push-quiet-end" type="time" value="07:00" /></label>
</div>
<span class="small" id="push-quiet-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
@ -369,6 +390,19 @@
</div>
</div>
</section>
<section class="progressive-work-detail" id="progressive-work-detail" role="dialog" aria-modal="true" aria-labelledby="progressive-work-detail-title" hidden>
<div class="progressive-work-detail-panel">
<header>
<div><p class="small muted">My Work</p><h2 id="progressive-work-detail-title">Work item</h2></div>
<button id="close-progressive-work-detail" type="button">Close</button>
</header>
<p class="small" id="progressive-work-detail-meta"></p>
<p id="progressive-work-detail-reason"></p>
<p class="muted">The full workspace is still loading. You can read this assignment now or open it in Gitea.</p>
<a class="button-link" id="open-progressive-work-gitea" href="" hidden>Open in Gitea</a>
</div>
</section>
<dialog class="release-receipt-sheet" id="release-receipt-sheet" aria-labelledby="release-receipt-title">
<section class="release-receipt-panel">
<header><div><p class="small muted">Exact merge evidence</p><h2 id="release-receipt-title">Release watchlist</h2></div><button id="close-release-receipt" type="button">Close</button></header>
@ -780,7 +814,7 @@
</section>
</div>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div id="cmd-palette" role="dialog" aria-modal="true" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
<div class="cmd-palette-header-actions">
@ -901,6 +935,7 @@
<div id="search-preview-comments"></div>
<div id="search-preview-conversation-status" class="small" aria-live="polite"></div>
<button id="retry-search-preview-conversation" type="button" hidden>Retry conversation</button>
<button class="conversation-actions-retry" id="retry-search-preview-comment-actions" type="button" hidden>Retry comment tools</button>
<button id="load-older-search-preview-comments" type="button" hidden>Load older messages</button>
</section>
<section id="search-preview-review" class="search-preview-review" aria-labelledby="search-preview-review-title" hidden>
@ -1018,6 +1053,7 @@
<div class="small" id="issue-sheet-key"></div>
<h3 id="issue-sheet-title">Assigned issue</h3>
</div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button id="close-issue-sheet" type="button">Close sheet</button>
</div>
<nav class="mobile-issue-detail-nav" aria-label="Issue sections">
@ -1518,6 +1554,7 @@
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<nav class="mobile-update-detail-nav" aria-label="Update sections">
@ -1641,6 +1678,7 @@
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
<div><div class="small" id="pull-sheet-key"></div><h3 id="pull-sheet-title">Assigned pull request</h3></div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button id="close-pull-sheet" type="button">Close</button>
</div>
<nav class="mobile-detail-nav mobile-pull-detail-nav" aria-label="Pull request sections">
@ -1741,6 +1779,46 @@
<p class="small" id="pull-feedback-location"></p>
<p id="pull-feedback-body"></p>
<div class="small" id="pull-feedback-context" aria-live="polite"></div>
<div class="pull-feedback-code-actions">
<button id="review-pull-feedback-suggestion" type="button" hidden>Review suggested change</button>
<button id="make-pull-feedback-fix" type="button" hidden>Edit full file</button>
</div>
<section class="pull-feedback-suggestion-preview" id="pull-feedback-suggestion-preview" aria-labelledby="pull-feedback-suggestion-heading" hidden>
<h5 id="pull-feedback-suggestion-heading">Suggested change</h5>
<div class="pull-feedback-suggestion-change">
<div><span class="small">Current line</span><pre id="pull-feedback-suggestion-before"></pre></div>
<div><span class="small">Suggested replacement</span><pre id="pull-feedback-suggestion-after"></pre></div>
</div>
<label for="pull-feedback-suggestion-message">Commit message</label>
<input id="pull-feedback-suggestion-message" maxlength="120" value="fix: apply review suggestion">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-suggestion" type="button" hidden>Cancel suggestion</button>
<button id="stage-pull-feedback-suggestion" type="button" hidden>Add to fix batch</button>
<button id="apply-pull-feedback-suggestion" type="button" hidden>Commit suggested change</button>
</div>
</section>
<section class="pull-feedback-batch-review" id="pull-feedback-batch-review" aria-labelledby="pull-feedback-batch-heading" hidden>
<h5 id="pull-feedback-batch-heading">Review fixes</h5>
<ul id="pull-feedback-batch-items"></ul>
<label for="pull-feedback-batch-message">Commit message</label>
<input id="pull-feedback-batch-message" maxlength="120" value="fix: apply review suggestions">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-batch" type="button" hidden>Keep reviewing</button>
<button id="commit-pull-feedback-batch" type="button" hidden>Commit all fixes</button>
</div>
</section>
<button id="review-pull-feedback-batch" type="button" hidden>Review fixes (0)</button>
<section class="pull-feedback-file-editor" id="pull-feedback-file-editor" hidden>
<h5>Edit <span id="pull-feedback-file-path"></span></h5>
<label for="pull-feedback-file-content">File content</label>
<textarea id="pull-feedback-file-content" spellcheck="false" maxlength="131072"></textarea>
<label for="pull-feedback-commit-message">Commit message</label>
<input id="pull-feedback-commit-message" maxlength="120" placeholder="fix: address review feedback">
<div class="pull-feedback-file-actions">
<button id="cancel-pull-feedback-fix" type="button" hidden>Cancel change</button>
<button id="commit-pull-feedback-fix" type="button" hidden>Commit to pull branch</button>
</div>
</section>
<fieldset class="pull-feedback-dispositions">
<legend>Disposition</legend>
<button type="button" data-feedback-disposition="addressed">Addressed</button>
@ -1837,6 +1915,7 @@
<div class="small" id="review-sheet-key"></div>
<h3 id="review-sheet-title">Pull request review</h3>
</div>
<button class="current-work-pin" data-current-work-pin type="button" hidden>Pin</button>
<button class="review-action" id="close-review-sheet">Close</button>
</div>
<nav class="mobile-review-detail-nav" aria-label="Review sections">
@ -2102,25 +2181,65 @@
<p id="mobile-start-day-summary" class="small" aria-live="polite">Reviewing urgent queues…</p>
<p id="mobile-start-day-phases" class="small muted">Checking Agenda, Attention, Updates, Filed, and Following</p>
</div>
<button class="mobile-start-day-action" id="mobile-start-day-action" type="button">Prepare Today</button>
<button class="mobile-start-day-finish" id="finish-mobile-start-day" type="button" hidden>Finish for now</button>
</section>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
<section class="mobile-queue-next" aria-labelledby="mobile-queue-next-heading">
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
<button id="mobile-queue-next-action" type="button">Find Work</button>
</section>
<p id="mobile-recent-work-status" role="status" aria-live="polite" class="small"></p>
<section class="mobile-queue-group" id="mobile-pinned-work" aria-labelledby="mobile-pinned-work-heading" hidden>
<h3 id="mobile-pinned-work-heading">Pinned work</h3>
<div class="mobile-queue-list" id="mobile-pinned-work-list"></div>
<button class="mobile-pinned-work-toggle" id="mobile-pinned-work-toggle" type="button" aria-controls="mobile-pinned-work-list" aria-expanded="false" hidden>Show all</button>
</section>
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
<h3 id="mobile-recent-work-heading">Recent work</h3>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section>
<details class="mobile-queue-priority" id="mobile-queue-priority">
<summary>Customize routine order</summary>
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
<div id="mobile-queue-priority-list" class="mobile-queue-priority-list"></div>
<div class="mobile-queue-priority-footer">
<button id="reset-mobile-queue-priority" type="button">Reset order</button>
<span id="mobile-queue-priority-status" role="status" aria-live="polite" class="small"></span>
</div>
<div id="mobile-queue-priority-conflict" class="mobile-queue-priority-conflict" hidden>
<p class="small">This routine was changed on another device. Choose which complete order to keep.</p>
<button id="keep-local-mobile-queue-priority" type="button">Keep this device</button>
<button id="use-remote-mobile-queue-priority" type="button">Use other device</button>
</div>
</details>
<section class="mobile-queue-group" aria-labelledby="mobile-queue-active-heading" hidden>
<h3 id="mobile-queue-active-heading">Active now</h3>
<div class="mobile-queue-list" id="mobile-queue-active-list"></div>
</section>
<section class="mobile-queue-group" aria-labelledby="mobile-queue-planning-heading">
<h3 id="mobile-queue-planning-heading">Plan &amp; organize</h3>
<div class="mobile-queue-list" id="mobile-queue-planning-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="tomorrow" type="button"><span><strong>Tomorrow</strong><small id="mobile-tomorrow-summary" aria-live="polite">Nothing planned</small></span></button>
<button data-mobile-queue="week" type="button"><span><strong>Week Ahead</strong><small id="mobile-week-summary" aria-live="polite">Nothing planned</small></span></button>
</div>
</section>
<details class="mobile-queue-all">
<summary>All queues</summary>
<div class="mobile-queue-list" id="mobile-queue-all-list">
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="gate" type="button"><span><strong>Human Gates</strong><small>Release decisions</small></span><span data-mobile-queue-count="gate">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="authored" type="button"><span><strong>My PRs</strong><small>Pull requests you authored</small></span><span data-mobile-queue-count="authored">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
</details>
</section>
</dialog>
@ -2217,6 +2336,7 @@
<script src="static/search-reply-draft-store.js"></script>
<script src="static/conversation-reply-draft-store.js"></script>
<script src="static/conversation-photo-drafts.js"></script>
<script src="static/photo-draft-inbox.js"></script>
<script src="static/search-defer.js"></script>
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
@ -2236,6 +2356,10 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/progressive-live-snapshot.js"></script>
<script src="static/progressive-my-work.js"></script>
<script src="static/progressive-mobile-dock.js"></script>
<script src="static/progressive-capture.js"></script>
<script src="static/agenda-replan.js"></script>
<script src="static/agenda-calendar.js"></script>
<script src="static/protect-today.js"></script>
@ -2306,6 +2430,8 @@
<script src="static/mobile-pull-refresh.js"></script>
<script src="static/mobile-first-task.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-recent-work.js"></script>
<script src="static/mobile-queue-priority.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
<script src="static/mobile-start-day.js"></script>
@ -2326,6 +2452,7 @@
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-search-modal.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/push-notifications.js"></script>
@ -2337,6 +2464,8 @@
<script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script>
<script src="static/human-gates.js"></script>
<script src="static/progressive-human-gates.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>

View File

@ -119,7 +119,7 @@
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
status.textContent = 'Stackchain locked. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;

View File

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

View File

@ -8,28 +8,110 @@
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
const continuation = [
['delivery', 'Recover Delivery'],
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['agenda', 'Open Agenda'],
['filed', 'Review Filed'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
const labels = {
delivery: 'Recover Delivery', gate: 'Review Human Gates', attention: 'Start Attention',
today: 'Continue Today', update: 'Resume Updates', agenda: 'Open Agenda',
following: 'Review Following', authored: 'Open My PRs', filed: 'Review Filed',
later: 'Start Later', draft: 'Open Drafts',
};
const criticalQueueNames = ['delivery', 'gate'];
const defaultRoutineOrder = [
'attention', 'today', 'update', 'agenda', 'following', 'authored', 'filed', 'later', 'draft',
];
const onlineOnlyQueues = new Set(['delivery', 'gate']);
const allQueues = [
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
];
function routineOrder() {
const proposed = options.getRoutineOrder ? options.getRoutineOrder() : defaultRoutineOrder;
if (!Array.isArray(proposed) || proposed.length !== defaultRoutineOrder.length ||
new Set(proposed).size !== defaultRoutineOrder.length ||
proposed.some(name => !defaultRoutineOrder.includes(name))) return defaultRoutineOrder.slice();
return proposed.slice();
}
function activeQueueNames() {
return criticalQueueNames.concat(routineOrder());
}
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
const match = continuation.find(([name]) => Number(counts[name]) > 0);
const online = options.isOnline ? options.isOnline() : true;
const match = activeQueueNames().map(name => [name, labels[name]]).find(([name]) =>
Number(counts[name]) > 0 && (online || !onlineOnlyQueues.has(name))
);
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
const [name, label] = match;
const count = Math.max(0, Number(counts[name]) || 0);
return {name, count, label: label + ' (' + count + ')'};
}
function adaptiveRecommendation() {
const preparation = options.getPreparation ? options.getPreparation() : null;
if (preparation && (preparation.active || Number(preparation.total) > 0)) {
const prefix = preparation.active ? 'Resume preparation · ' : 'Start day · ';
return {name: 'prepare', count: Math.max(0, Number(preparation.total) || 0), label: prefix + preparation.label};
}
return recommend();
}
function presentation() {
const counts = options.getCounts ? options.getCounts() : {};
const names = activeQueueNames();
const active = names
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
.filter(item => item.count > 0);
names.forEach(name => {
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
active.push({name, unavailable: true});
}
});
return {
nextUp: adaptiveRecommendation(),
active,
planning: ['today', 'tomorrow', 'week'],
all: allQueues.slice(),
};
}
function renderPresentation() {
const view = presentation();
const planning = new Set(view.planning);
const active = view.active.filter(item => !planning.has(item.name));
const activeNames = new Set(active.map(item => item.name));
if (options.nextAction) {
options.nextAction.textContent = view.nextUp.label;
options.nextAction.dataset.queue = view.nextUp.name;
const prefix = view.nextUp.name === 'prepare' ? 'Start or continue: ' : 'Next up: ';
options.nextAction.setAttribute('aria-label', prefix + view.nextUp.label);
}
active.forEach(item => {
const row = options.rows?.[item.name];
if (!row) return;
if (item.unavailable) row.setAttribute('data-unavailable', 'true');
else row.removeAttribute('data-unavailable');
options.activeList?.append(row);
});
view.planning.forEach(name => {
const row = options.rows?.[name];
if (row) options.planningList?.append(row);
});
view.all.forEach(name => {
if (planning.has(name) || activeNames.has(name)) return;
const row = options.rows?.[name];
if (!row) return;
row.removeAttribute('data-unavailable');
options.allList?.append(row);
});
if (options.activeSection) options.activeSection.hidden = active.length === 0;
return view;
}
function open(name) {
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
if (name === 'gate' && options.openHumanGates) return options.openHumanGates();
if (name === 'today') return options.openToday();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
@ -46,7 +128,11 @@
}
function continueWork() {
const next = recommend();
const next = adaptiveRecommendation();
if (next.name === 'prepare') {
options.openPreparation();
return 'prepare';
}
if (next.name === 'find') {
options.openFindWork();
return 'find';
@ -54,5 +140,5 @@
return open(next.name);
}
return { open, recommend, continueWork };
return { open, recommend, adaptiveRecommendation, presentation, renderPresentation, continueWork };
});

View File

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

View File

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

View File

@ -0,0 +1,83 @@
(function (root, factory) {
const createMobileSearchModal = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = createMobileSearchModal;
else root.createMobileSearchModal = createMobileSearchModal;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const FOCUSABLE = [
'a[href]', 'button', 'input', 'select', 'textarea',
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
].join(',');
return function createMobileSearchModal(options) {
const documentRef = options.document;
const backgrounds = Array.from(options.backgrounds || [
documentRef.querySelector?.('body > header'),
documentRef.querySelector?.('main'),
documentRef.querySelector?.('#mobile-task-dock'),
]).filter(Boolean);
let surface = null;
let opener = null;
let priorInert = null;
function focusable() {
if (!surface || typeof surface.querySelectorAll !== 'function') return [];
return Array.from(surface.querySelectorAll(FOCUSABLE)).filter(element =>
!element.disabled && !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' &&
(typeof element.matches !== 'function' || element.matches(':not([hidden])')) &&
(typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
);
}
function focus(element) {
if (element?.isConnected !== false && typeof element?.focus === 'function') element.focus();
}
function onKeydown(event) {
if (!surface || event.key !== 'Tab') return;
const controls = focusable();
if (!controls.length) {
event.preventDefault();
return;
}
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && documentRef.activeElement === first) {
event.preventDefault();
focus(last);
} else if (!event.shiftKey && documentRef.activeElement === last) {
event.preventDefault();
focus(first);
} else if (!controls.includes(documentRef.activeElement)) {
event.preventDefault();
focus(event.shiftKey ? last : first);
}
}
function transition(nextSurface, transitionOptions = {}) {
surface = nextSurface;
focus(transitionOptions.initialFocus || focusable()[0]);
}
function activate(nextSurface, activateOptions = {}) {
if (!surface) {
opener = activateOptions.opener || documentRef.activeElement || null;
priorInert = backgrounds.map(element => element.inert === true);
backgrounds.forEach(element => { element.inert = true; });
}
transition(nextSurface, activateOptions);
}
function deactivate() {
if (!surface) return;
backgrounds.forEach((element, index) => { element.inert = priorInert[index]; });
surface = null;
priorInert = null;
const restore = opener;
opener = null;
focus(restore);
}
documentRef.addEventListener('keydown', onKeydown);
return { activate, transition, deactivate, current: () => surface };
};
});

View File

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

View File

@ -70,9 +70,10 @@
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
prepare:'Prepare', 'prepare-resume':'Resume prep',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda',
following:'Following', authored:'My PRs', filed:'Filed', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Following', 'My PRs', 'Filed', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
const actionLabel = mode === 'prepare' ? 'Prepare Today' :
mode === 'prepare-resume' ? 'Resume preparation' :
@ -97,9 +98,9 @@
}
function updateQueues(counts) {
const names = 'today agenda delivery attention update filed later draft'.split(' ');
const names = 'today agenda delivery gate attention update following filed authored later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
const actionableNames = ['today', 'delivery', 'gate', 'attention', 'update', 'following', 'filed', 'authored', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
@ -107,6 +108,9 @@
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
options.queueRows?.following?.setAttribute(
'aria-label', 'Following, ' + normalized.following + ' unseen changes'
);
if (options.queueBadge) {
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
@ -124,9 +128,12 @@
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Human Gates ' + normalized.gate
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Following ' + normalized.following
+ ', Filed ' + normalized.filed
+ ', My PRs ' + normalized.authored
+ ', Later ' + normalized.later
+ ', Drafts ' + normalized.draft
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');

View File

@ -0,0 +1,80 @@
(function (root, factory) {
const createPhotoDraftInbox = factory();
if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox;
else root.createPhotoDraftInbox = createPhotoDraftInbox;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
function targetKey(route) {
if (route?.kind === 'update') {
const notificationId = Number(route.notification_id || 0);
return notificationId > 0 ? 'update:' + notificationId : '';
}
const kind = route?.kind === 'search' ? route.target_kind : route?.kind;
const repository = String(route?.repository || '');
const number = Number(route?.number || 0);
return ['issue', 'pull'].includes(kind) && repository && number > 0 ?
kind + ':' + repository.toLowerCase() + '#' + number : '';
}
function removeTarget(item) {
const route = item?.route || {};
if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) };
return {
kind:route.kind === 'search' ? route.target_kind : route.kind,
repository:route.repository,
number:Number(route.number),
};
}
return function createPhotoDraftInbox({ conversation, search }) {
let items = [];
async function refresh(existing = []) {
const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean));
const results = await Promise.allSettled([
conversation?.list?.() || [],
search?.list?.() || [],
]);
items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : [])
.filter(item => {
const key = targetKey(item?.route);
if (!key || occupied.has(key)) return false;
occupied.add(key);
return true;
})
.sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ||
String(left.id || '').localeCompare(String(right.id || '')));
return items.slice();
}
function list() { return items.slice(); }
async function discard(item) {
if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) {
return false;
}
const store = item.photo_store === 'search' ? search : conversation;
if (!store?.remove) return false;
await store.remove(removeTarget(item));
items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store));
return true;
}
function description(item) {
return item?.kind === 'photo-reply' ?
Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') :
(item?.preview || 'Unfinished draft');
}
function searchTarget(item) {
const route = item?.route || {};
return {
kind:route.target_kind, repository:route.repository,
number:route.number, title:item?.title,
};
}
return { refresh, list, discard, description, searchTarget };
};
});

View File

@ -0,0 +1,182 @@
function createProgressiveCapture({
document,
storage,
getLogin = () => '',
createCaptures = globalThis.createUnfiledCaptures,
requestFullWorkspace = async () => {
const lifecycle = await globalThis.stackchainWorkspaceLifecycle;
return lifecycle?.optionalReady;
},
createId,
now,
}) {
const button = document.querySelector('[data-mobile-task="new"]');
const root = document.querySelector('#create-issue-sheet');
const title = document.querySelector('#create-issue-title');
const body = document.querySelector('#create-issue-body');
const heading = document.querySelector('#create-issue-heading');
const save = document.querySelector('#save-unfiled-issue');
const file = document.querySelector('#file-new-issue');
const cancel = document.querySelector('#cancel-new-issue');
const status = document.querySelector('#my-work-action-status');
const captures = createCaptures({
storage,
getCaptureLogin:getLogin,
getCurrentLogin:getLogin,
...(createId ? {createId} : {}),
...(now ? {now} : {}),
});
const listeners = [];
let started = false;
let saving = null;
let saved = false;
let handedOff = false;
const checkpointKey = 'stackchain.progressive-capture.v1';
function listen(element, name, callback) {
element?.addEventListener?.(name, callback);
listeners.push([element, name, callback]);
}
function close() {
root?.classList.remove('open');
}
function readCheckpoints() {
try {
const record = JSON.parse(storage?.getItem(checkpointKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item => item && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && typeof item.body === 'string');
} catch (_error) { return []; }
}
function readCheckpoint() {
const login = String(getLogin() || '').trim();
if (!login) return null;
return readCheckpoints().find(item => item.ownerLogin === login) || null;
}
function checkpoint() {
const ownerLogin = String(getLogin() || '').trim();
if (!ownerLogin) return false;
try {
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
items.push({
ownerLogin,
title:String(title?.value || '').slice(0, 255),
body:String(body?.value || '').slice(0, 10000),
});
storage?.setItem(checkpointKey, JSON.stringify({
version:1, items,
}));
return true;
} catch (_error) { return false; }
}
function clearCheckpoint(expected) {
const current = readCheckpoint();
if (!current || current.title !== expected.title || current.body !== expected.body) return false;
try {
const ownerLogin = String(getLogin() || '').trim();
const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin);
if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items}));
else storage?.removeItem(checkpointKey);
return true;
} catch (_error) { return false; }
}
function open(event) {
saved = false;
const recovered = readCheckpoint();
if (recovered) {
if (title) title.value = recovered.title;
if (body) body.value = recovered.body;
if (status) status.textContent = 'Unfinished capture restored from this phone.';
}
if (heading) heading.textContent = 'Capture work';
root?.classList.add('open');
title?.focus?.();
}
async function saveDraft() {
if (saved || saving) return saving;
save.disabled = true;
const draft = {title:title?.value || '', body:body?.value || ''};
saving = Promise.resolve().then(() => captures.save(draft)).then(() => {
saved = true;
clearCheckpoint(draft);
if (title) title.value = '';
if (body) body.value = '';
close();
if (status) status.textContent = 'Saved to Drafts. Choose a repository when youre ready to file it.';
return true;
}).catch(error => {
if (status) status.textContent = error.message;
title?.focus?.();
return false;
}).finally(() => {
save.disabled = false;
saving = null;
});
return saving;
}
async function fileNow() {
if (file.disabled) return false;
file.disabled = true;
if (status) status.textContent = 'Loading filing tools…';
try {
await requestFullWorkspace();
return true;
} catch (_error) {
file.disabled = false;
if (status) status.textContent = 'Filing tools unavailable. Your capture is still editable; retry when connected.';
title?.focus?.();
return false;
}
}
function start() {
if (started) return false;
started = true;
root?.classList.add('progressive-capture');
listen(button, 'click', open);
listen(save, 'click', saveDraft);
listen(file, 'click', fileNow);
listen(cancel, 'click', close);
listen(title, 'input', checkpoint);
listen(body, 'input', checkpoint);
return true;
}
function handoff() {
if (handedOff) return null;
handedOff = true;
const state = {
open:Boolean(root?.classList.contains('open')),
title:title?.value || '', body:body?.value || '',
};
state.complete = () => clearCheckpoint(state);
root?.classList.remove('progressive-capture');
file.disabled = false;
listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback));
listeners.length = 0;
return state;
}
return {start, handoff};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
typeof createUnfiledCaptures === 'function' &&
window.matchMedia?.('(max-width: 600px)').matches === true) {
window.stackchainProgressiveCapture = createProgressiveCapture({
document,
storage:window.localStorage,
getLogin:() => window.stackchainProgressiveMyWork?.login?.() || '',
});
window.stackchainProgressiveCapture.start();
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveCapture;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,278 @@
function createProgressiveMyWork({
document, fetchSnapshot, liveSnapshot: snapshotBroker = null,
pollerOptions = {}, lifecycleTarget = globalThis,
}) {
const list = document.querySelector('#my-work-list');
const status = document.querySelector('#my-work-status');
const detail = document.querySelector('#progressive-work-detail');
const detailTitle = document.querySelector('#progressive-work-detail-title');
const detailMeta = document.querySelector('#progressive-work-detail-meta');
const detailReason = document.querySelector('#progressive-work-detail-reason');
const closeDetail = document.querySelector('#close-progressive-work-detail');
const openGitea = document.querySelector('#open-progressive-work-gitea');
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
const listeners = [];
const lifecycleListeners = [];
const subscribers = new Set();
let items = [];
let active = 'all';
let stopped = false;
let selectedByUser = false;
let liveSnapshot = null;
let liveSnapshotPromise = null;
let confirmedLogin = '';
let poller = null;
let detailTrigger = null;
let openWork = null;
let contextUnavailable = false;
const deferredQueues = {
today:'Today', agenda:'Agenda', later:'Later', draft:'Drafts',
};
const fetchProgressiveSnapshot = (revisions = {}, options = {}) =>
snapshotBroker && Object.keys(revisions || {}).length === 0 ?
snapshotBroker.acquire() : fetchSnapshot(revisions, options);
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
})[character]);
const safeUrl = value => {
try {
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_error) { return ''; }
};
const matches = (item, filter) =>
filter === 'review' ? item.is_review :
filter === 'update' ? item.has_update :
filter === 'attention' ? item.needs_attention :
filter === 'filed' ? item.is_filed :
filter === 'authored' ? item.is_authored :
filter === 'pull' ? item.kind === 'pull' && !item.is_review : item.kind === filter;
const visibleItems = () => active === 'all' ? items : items.filter(item => matches(item, active));
const notify = () => subscribers.forEach(subscriber => subscriber());
const queueCounts = () => ({
all:items.length,
filed:items.filter(item => matches(item, 'filed')).length,
authored:items.filter(item => matches(item, 'authored')).length,
attention:items.filter(item => matches(item, 'attention')).length,
update:items.filter(item => matches(item, 'update')).length,
review:items.filter(item => matches(item, 'review')).length,
});
const closeProgressiveDetail = ({ restoreFocus = true } = {}) => {
if (!detail || detail.hidden) return;
detail.hidden = true;
if (restoreFocus) detailTrigger?.focus?.();
detailTrigger = null;
openWork = null;
};
const openProgressiveDetail = (item, trigger) => {
if (!detail || !item) return;
detailTrigger = trigger;
openWork = item;
detailTitle.textContent = item.title || item.key || 'Untitled work';
detailMeta.textContent = (item.key || 'Unknown work item') + ' · ' +
(item.kind === 'pull' ? 'Pull request' : 'Issue');
detailReason.textContent = item.reason || 'Assigned to you';
const href = safeUrl(item.url);
openGitea.hidden = !href;
if (href) openGitea.href = href;
else openGitea.removeAttribute?.('href');
detail.hidden = false;
closeDetail?.focus?.();
};
const updateCounts = () => {
['all','attention','filed','authored','issue','pull','review','update'].forEach(filter => {
const count = filter === 'all' ? items.length : items.filter(item => matches(item, filter)).length;
const element = document.querySelector('[data-work-count="' + filter + '"]');
if (element) element.textContent = String(count);
});
};
const render = () => {
if (stopped || !list) return;
const visible = visibleItems();
list.innerHTML = contextUnavailable && !items.length ?
'<div class="muted">Assigned work is reconnecting…</div>' : deferredQueues[active] ?
'<div class="muted">' + deferredQueues[active] + ' is still loading…</div>' : visible.length ? visible.map((item, index) => {
const title = escapeHtml(item.title || item.key || 'Untitled work');
const context = escapeHtml(item.key || '');
const reason = escapeHtml(item.reason || 'Assigned to you');
return '<article class="my-work-card progressive-my-work-card">' +
'<button class="my-work-card-main" type="button" data-progressive-work-index="' + index + '">' +
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
'</button></article>';
}).join('') : '<div class="muted">No work in this queue.</div>';
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
};
filters.forEach(button => {
const listener = () => { active = button.dataset.workFilter || 'all'; selectedByUser = true; render(); };
button.addEventListener('click', listener);
listeners.push([button, listener]);
});
const openListener = event => {
const trigger = event.target?.closest?.('[data-progressive-work-index]');
if (!trigger) return;
const item = visibleItems()[Number(trigger.dataset.progressiveWorkIndex)];
if (!item) return;
event.preventDefault?.();
openProgressiveDetail(item, trigger);
};
const closeListener = () => closeProgressiveDetail();
const keyListener = event => {
if (event.key !== 'Escape' || detail?.hidden) return;
event.preventDefault?.();
closeProgressiveDetail();
};
list?.addEventListener?.('click', openListener);
closeDetail?.addEventListener?.('click', closeListener);
lifecycleTarget.addEventListener?.('keydown', keyListener);
const applySnapshot = snapshot => {
if (stopped) return false;
const transferable = snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context');
const contextDegraded = transferable && (
!snapshot.context || snapshot.freshness?.sections?.context?.degraded
);
if (contextDegraded) {
contextUnavailable = true;
render();
notify();
if (status) status.textContent = 'Assigned work is reconnecting…';
return false;
}
contextUnavailable = false;
if (transferable) liveSnapshot = snapshot;
const context = snapshot?.context || snapshot || {};
confirmedLogin = String(context.user?.login || '').trim();
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
updateCounts();
render();
notify();
const assigned = items.filter(item => item.is_assigned).length;
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
return true;
};
if (typeof createContextPoller === 'function') {
poller = createContextPoller({
...pollerOptions,
fetchContext: fetchProgressiveSnapshot,
onSnapshot: applySnapshot,
onError: () => {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
},
isHidden: pollerOptions.isHidden || (() => Boolean(document.hidden)),
});
const recover = () => {
if (!document.hidden) void poller.refresh();
};
['online', 'visibilitychange'].forEach(eventName => {
lifecycleTarget.addEventListener?.(eventName, recover);
lifecycleListeners.push([eventName, recover]);
});
}
return {
login() { return confirmedLogin; },
counts() { return queueCounts(); },
subscribe(subscriber) {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
openFirst() {
const item = visibleItems()[0];
if (!item) return deferredQueues[active] ? 'loading' : 'empty';
const trigger = list?.querySelector?.('[data-progressive-work-index="0"]') || null;
openProgressiveDetail(item, trigger);
return 'opened';
},
selectQueue(name, {openFirst = false} = {}) {
const allowed = new Set(['all','attention','filed','authored','issue','pull','review','update','today','agenda','later','draft']);
if (!allowed.has(name)) return 'unsupported';
active = name;
selectedByUser = true;
render();
notify();
return openFirst ? this.openFirst() : 'selected';
},
handoff() {
const state = { selectedFilter:selectedByUser ? active : null };
if (openWork) {
state.openWork = {
kind:openWork.kind, key:openWork.key, number:openWork.number,
repository:openWork.repository,
};
openWork = null;
}
if (liveSnapshot) {
state.liveSnapshot = liveSnapshot;
liveSnapshot = null;
liveSnapshotPromise = null;
} else if (liveSnapshotPromise) {
state.liveSnapshotPromise = liveSnapshotPromise;
liveSnapshotPromise = null;
}
return state;
},
async start() {
if (status) status.textContent = 'Loading assigned work…';
if (poller) {
const request = poller.start();
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
));
return Boolean(await request);
}
const request = Promise.resolve().then(() => fetchProgressiveSnapshot());
liveSnapshotPromise = request.then(snapshot => (
snapshot && typeof snapshot === 'object' &&
Object.prototype.hasOwnProperty.call(snapshot, 'context') ? snapshot : null
), () => null);
try {
const snapshot = await request;
if (stopped) return false;
const applied = applySnapshot(snapshot);
if (!liveSnapshot) liveSnapshotPromise = null;
return applied;
} catch (_error) {
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
return false;
}
},
stop() {
stopped = true;
poller?.stop();
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
list?.removeEventListener?.('click', openListener);
closeDetail?.removeEventListener?.('click', closeListener);
lifecycleTarget.removeEventListener?.('keydown', keyListener);
lifecycleListeners.forEach(([eventName, listener]) =>
lifecycleTarget.removeEventListener?.(eventName, listener));
subscribers.clear();
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.stackchainProgressiveMyWork = createProgressiveMyWork({
document,
lifecycleTarget: window,
liveSnapshot:window.stackchainProgressiveLiveSnapshot,
fetchSnapshot: async (revisions = {}, { signal } = {}) => {
const query = createContextPoller.buildRevisionQuery(revisions);
const response = await fetch('api/v1/live' + (query ? '?' + query : ''), {
headers:{Accept:'application/json'}, signal,
});
if (!response.ok) {
const error = new Error('HTTP ' + response.status);
error.retryAfterMs = createContextPoller.retryAfterMs(response.headers.get('Retry-After'));
throw error;
}
return response.json();
},
});
void window.stackchainProgressiveMyWork.start();
}
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;

View File

@ -413,11 +413,49 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const save = () => {
if (active) controller.saveFeedbackPass(active.item, active.detail, active.reviewer, active.login, active.state);
};
const clearSuggestion = () => {
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#cancel-pull-feedback-suggestion').hidden = true;
qs('#stage-pull-feedback-suggestion').hidden = true;
qs('#apply-pull-feedback-suggestion').hidden = true;
if (active) active.suggestion = null;
};
const suggestedFile = (file, suggestion) => {
const line = Number(suggestion?.line);
if (!Number.isInteger(line) || line < 1 || typeof suggestion?.content !== 'string') return null;
const trailingNewline = file.content.endsWith('\n');
const lines = file.content.split('\n');
if (trailingNewline) lines.pop();
if (line > lines.length) return null;
const before = lines[line - 1];
lines.splice(line - 1, 1, ...suggestion.content.split('\n'));
return { before, after:suggestion.content, content:lines.join('\n') + (trailingNewline ? '\n' : '') };
};
const renderBatchButton = () => {
const count = active?.batch ? Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0) : 0;
const button = qs('#review-pull-feedback-batch');
button.hidden = count < 2;
button.textContent = 'Review fixes (' + count + ')';
};
const rebuildBatchFile = entry => {
const trailingNewline = entry.file.content.endsWith('\n');
const lines = entry.file.content.split('\n');
if (trailingNewline) lines.pop();
const ordered = [...entry.suggestions].sort((left, right) => right.line - left.line);
if (new Set(ordered.map(item => item.line)).size !== ordered.length) return null;
for (const suggestion of ordered) {
if (suggestion.line < 1 || suggestion.line > lines.length) return null;
lines.splice(suggestion.line - 1, 1, ...suggestion.content.split('\n'));
}
return lines.join('\n') + (trailingNewline ? '\n' : '');
};
const render = () => {
if (!active) return;
const items = comments();
active.state.index = Math.max(0, Math.min(Number(active.state.index) || 0, items.length - 1));
const comment = current();
if (active.suggestion && active.suggestion.commentId !== comment.id) clearSuggestion();
const response = active.state.responses[String(comment.id)] || {};
qs('#pull-feedback-progress').textContent = 'Comment ' + (active.state.index + 1) + ' of ' + items.length;
qs('#pull-feedback-heading').textContent = 'Address @' + active.reviewer.login + 's feedback';
@ -432,6 +470,16 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
qs('#finish-pull-feedback').disabled = active.state.posted === true ||
items.some(item => !active.state.responses[String(item.id)]?.disposition);
qs('#request-feedback-review').hidden = active.state.posted !== true;
qs('#make-pull-feedback-fix').hidden = active.detail?.capabilities?.authored !== true ||
active.detail?.state !== 'open' || active.detail?.merged === true || !comment.path;
const canSuggest = active.detail?.capabilities?.authored === true && active.detail?.state === 'open' &&
active.detail?.merged !== true && active.reviewer?.head_sha === active.detail?.head_sha &&
comment.suggestion?.line === comment.line && typeof comment.suggestion?.content === 'string';
qs('#review-pull-feedback-suggestion').hidden = !canSuggest;
qs('#review-pull-feedback-suggestion').disabled = canSuggest && globalThis.navigator?.onLine === false;
if (canSuggest && globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
}
const found = focusPullFile(doc, comment.path);
qs('#pull-feedback-context').textContent = found ?
'Matching change opened below.' : 'Matching change is unavailable in this preview.';
@ -472,13 +520,235 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
const reviewer = (detail?.reviewers || []).find(candidate => candidate.review_id === reviewId);
if (!detail || !item || !reviewer) return;
const login = getLogin?.() || '';
active = { item, detail, reviewer, login,
active = { item, detail, reviewer, login, batch:{},
state:controller.loadFeedbackPass(item, detail, reviewer, login) };
panel.hidden = false;
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = true;
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
active.suggestion = null;
renderBatchButton();
qs('#pull-feedback-status').textContent = active.state.posted ?
'Response already posted. You can request an updated review.' : 'Progress saves on this device.';
render();
});
qs('#review-pull-feedback-suggestion').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.suggestion || globalThis.navigator?.onLine === false) {
qs('#pull-feedback-status').textContent = 'Reconnect to apply suggestion.';
return;
}
const button = qs('#review-pull-feedback-suggestion');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current line for review…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
const change = suggestedFile(file, comment.suggestion);
if (!change) throw new Error('The suggested line is no longer available.');
active.suggestion = { file, content:change.content, commentId:comment.id };
qs('#pull-feedback-suggestion-before').textContent = change.before;
qs('#pull-feedback-suggestion-after').textContent = change.after;
qs('#pull-feedback-suggestion-message').value = 'fix: apply review suggestion';
qs('#pull-feedback-file-editor').hidden = true;
qs('#pull-feedback-suggestion-preview').hidden = false;
qs('#cancel-pull-feedback-suggestion').hidden = false;
qs('#stage-pull-feedback-suggestion').hidden = false;
qs('#apply-pull-feedback-suggestion').hidden = false;
qs('#pull-feedback-status').textContent = 'Review the bounded replacement before committing.';
qs('#apply-pull-feedback-suggestion').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Use the full-file editor if needed.';
button.focus();
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-suggestion').addEventListener('click', () => {
clearSuggestion();
qs('#review-pull-feedback-suggestion').focus();
});
qs('#stage-pull-feedback-suggestion').addEventListener('click', () => {
if (!active?.suggestion) return;
const pending = active.suggestion;
const path = pending.file.path;
const stagedCount = Object.values(active.batch)
.reduce((total, file) => total + file.suggestions.length, 0);
if (stagedCount >= 8) {
qs('#pull-feedback-status').textContent = 'Commit this bounded batch before staging more suggestions.';
return;
}
const entry = active.batch[path] || {file:pending.file, suggestions:[]};
if (entry.file.blob_sha !== pending.file.blob_sha ||
entry.suggestions.some(item => item.commentId === pending.commentId)) return;
const comment = current();
entry.suggestions.push({
line:Number(comment.suggestion.line), content:comment.suggestion.content,
commentId:pending.commentId,
});
entry.content = rebuildBatchFile(entry);
if (entry.content === null) {
entry.suggestions.pop();
qs('#pull-feedback-status').textContent = 'This suggestion overlaps a staged fix. Keep one of the conflicting changes.';
return;
}
active.batch[path] = entry;
clearSuggestion();
renderBatchButton();
qs('#pull-feedback-status').textContent = 'Suggestion added to the fix batch. Keep reviewing this feedback.';
qs('#next-pull-feedback').focus();
});
qs('#review-pull-feedback-batch').addEventListener('click', () => {
if (!active?.batch) return;
const list = qs('#pull-feedback-batch-items');
list.replaceChildren(...Object.values(active.batch).map(entry => {
const item = doc.createElement('li');
item.textContent = entry.file.path + ' · ' + entry.suggestions.length +
(entry.suggestions.length === 1 ? ' suggestion' : ' suggestions');
return item;
}));
qs('#pull-feedback-batch-review').hidden = false;
qs('#cancel-pull-feedback-batch').hidden = false;
qs('#commit-pull-feedback-batch').hidden = false;
qs('#pull-feedback-batch-review').scrollIntoView({block:'center', behavior:'smooth'});
qs('#commit-pull-feedback-batch').focus();
});
qs('#cancel-pull-feedback-batch').addEventListener('click', () => {
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
qs('#review-pull-feedback-batch').focus();
});
qs('#commit-pull-feedback-batch').addEventListener('click', async () => {
if (!active?.batch) return;
const entries = Object.values(active.batch);
const count = entries.reduce((total, entry) => total + entry.suggestions.length, 0);
const message = qs('#pull-feedback-batch-message').value.trim();
if (count < 2 || !message) return;
if (!globalThis.confirm('Commit ' + count + ' suggested changes to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-batch');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying all suggested changes…';
try {
const result = await controller.commitFeedbackBatch(active.item, {
files:entries.map(entry => ({
path:entry.file.path, content:entry.content,
expectedBlobSha:entry.file.blob_sha,
})),
suggestionCount:count, message, expectedHeadSha:active.detail.head_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.batch = {};
save();
qs('#pull-feedback-batch-review').hidden = true;
qs('#cancel-pull-feedback-batch').hidden = true;
qs('#commit-pull-feedback-batch').hidden = true;
renderBatchButton();
qs('#pull-feedback-status').textContent = count + ' suggested changes committed. Mark each comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Batch kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#apply-pull-feedback-suggestion').addEventListener('click', async () => {
if (!active?.suggestion) return;
const message = qs('#pull-feedback-suggestion-message').value.trim();
if (!message) {
qs('#pull-feedback-status').textContent = 'Enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this suggested change to ' + active.item.key + '?')) return;
const button = qs('#apply-pull-feedback-suggestion');
button.disabled = true;
const pending = active.suggestion;
qs('#pull-feedback-status').textContent = 'Committing and verifying the suggested change…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:pending.file.path, content:pending.content, message,
expectedHeadSha:pending.file.head_sha, expectedBlobSha:pending.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
clearSuggestion();
save();
qs('#pull-feedback-status').textContent = 'Suggested change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Preview kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
qs('#make-pull-feedback-fix').addEventListener('click', async () => {
const comment = current();
if (!active || !comment?.path || active.detail?.capabilities?.authored !== true) return;
const button = qs('#make-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Loading the current pull file…';
try {
const file = await controller.loadFeedbackFile(active.item, comment.path, active.detail.head_sha);
if (!active || file.head_sha !== active.detail.head_sha || file.path !== comment.path) return;
active.file = file;
clearSuggestion();
qs('#pull-feedback-file-path').textContent = file.path;
qs('#pull-feedback-file-content').value = file.content;
qs('#pull-feedback-commit-message').value = 'fix: address review feedback';
qs('#pull-feedback-file-editor').hidden = false;
qs('#cancel-pull-feedback-fix').hidden = false;
qs('#commit-pull-feedback-fix').hidden = false;
qs('#pull-feedback-status').textContent = 'Edit the bounded file, then commit it to this pull branch.';
qs('#pull-feedback-file-content').focus();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Retry without losing your feedback progress.';
} finally {
button.disabled = false;
}
});
qs('#cancel-pull-feedback-fix').addEventListener('click', () => {
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
active.file = null;
qs('#make-pull-feedback-fix').focus();
});
qs('#commit-pull-feedback-fix').addEventListener('click', async () => {
if (!active?.file) return;
const content = qs('#pull-feedback-file-content').value;
const message = qs('#pull-feedback-commit-message').value.trim();
if (!message || content === active.file.content) {
qs('#pull-feedback-status').textContent = 'Change the file and enter a commit message before committing.';
return;
}
if (!globalThis.confirm('Commit this change to ' + active.item.key + '?')) return;
const button = qs('#commit-pull-feedback-fix');
button.disabled = true;
qs('#pull-feedback-status').textContent = 'Committing and verifying the new pull head…';
try {
const result = await controller.commitFeedbackFix(active.item, {
path:active.file.path, content, message,
expectedHeadSha:active.file.head_sha, expectedBlobSha:active.file.blob_sha,
});
active.detail.head_sha = result.head_sha;
active.state.headSha = result.head_sha;
active.file = null;
save();
qs('#pull-feedback-file-editor').hidden = true;
qs('#cancel-pull-feedback-fix').hidden = true;
qs('#commit-pull-feedback-fix').hidden = true;
qs('#pull-feedback-status').textContent = 'Code change committed. Mark the comment Addressed when satisfied.';
render();
} catch (error) {
qs('#pull-feedback-status').textContent = error.message + ' Draft kept; reload if the branch changed, or retry.';
button.disabled = false;
button.focus();
}
});
panel.querySelector('.pull-feedback-dispositions').addEventListener('click', event => {
const disposition = event.target.dataset?.feedbackDisposition;
const comment = current();
@ -701,6 +971,7 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
let lifecycleMutation = null;
let editRequest = null;
let feedbackRequest = null;
let feedbackFixRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const feedbackRequests = new Map();
@ -764,6 +1035,55 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
feedbackRequests.set(key, request);
return request;
},
loadFeedbackFile(item, path, expectedHeadSha) {
return fetchJson(pathFor(item) + '/feedback-file?path=' + encodeURIComponent(path) +
'&expected_head_sha=' + encodeURIComponent(expectedHeadSha), {
headers:{ Accept:'application/json' },
});
},
commitFeedbackFix(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-file', {
method:'PATCH',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
path:draft.path, content:draft.content, message:draft.message,
expected_head_sha:draft.expectedHeadSha,
expected_blob_sha:draft.expectedBlobSha,
}),
}).then(result => {
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha || result.path !== draft.path) {
throw new Error('The feedback fix was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
commitFeedbackBatch(item, draft) {
if (feedbackFixRequest) return feedbackFixRequest;
feedbackFixRequest = fetchJson(pathFor(item) + '/feedback-batch', {
method:'POST',
headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify({
files:draft.files.map(file => ({
path:file.path, content:file.content,
expected_blob_sha:file.expectedBlobSha,
})),
suggestion_count:draft.suggestionCount,
message:draft.message, expected_head_sha:draft.expectedHeadSha,
}),
}).then(result => {
const expectedPaths = draft.files.map(file => file.path);
if (!result?.head_sha || result.head_sha === draft.expectedHeadSha ||
!Array.isArray(result.paths) || result.paths.length !== expectedPaths.length ||
result.paths.some((path, index) => path !== expectedPaths[index]) ||
result.suggestion_count !== draft.suggestionCount) {
throw new Error('The suggestion batch was not confirmed.');
}
return result;
}).finally(() => { feedbackFixRequest = null; });
return feedbackFixRequest;
},
loadChecks(item) {
if (checkRequest) return checkRequest;
checkRequest = fetchJson(pathFor(item) + '/checks', {

View File

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

View File

@ -1,10 +1,11 @@
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, dialog = null, statusNode = null, checksNode = null, releaseNode = null, listNode = null, documentRef = null, windowRef = null, confirmAction = null, openPull = null, setTimer = setTimeout, clearTimer = clearTimeout, pollMs = 30000 }) {
const prefix = 'stackchain.release-receipt.v1:';
const limit = 12;
let entries = [];
let refreshing = null;
const recoveries = new Map();
const retrying = new Map();
const rollingBack = new Map();
let timer = null;
let bound = false;
documentRef ||= typeof document !== 'undefined' ? document : null;
@ -149,6 +150,12 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
: 'Branch ' + entry.source_branch + ' · ' + entry.source_head_sha.slice(0, 8);
copy.append(branch);
}
if (entry.rollback?.state === 'prepared') {
const rollbackStatus = document.createElement('span');
rollbackStatus.className = 'small release-rollback-status';
rollbackStatus.textContent = entry.rollback.message;
copy.append(rollbackStatus);
}
if (entry.status?.release?.url) {
const link = document.createElement('a');
link.href = entry.status.release.url;
@ -202,6 +209,21 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
render();
}));
actions.append(retry);
const rollback = document.createElement('button');
rollback.type = 'button';
rollback.textContent = entry.rollback?.state === 'prepared'
? 'Open rollback #' + entry.rollback.number
: (rollingBack.has(identity(entry)) ? 'Preparing rollback…' : 'Prepare rollback PR');
rollback.disabled = rollingBack.has(identity(entry));
rollback.setAttribute(
'aria-label',
'Prepare rollback pull request for ' + entry.key + ' at ' + entry.commit_sha.slice(0, 8),
);
rollback.addEventListener('click', () => prepareRollback(entry.repository, entry.commit_sha).catch(error => {
if (statusNode) statusNode.textContent = String(error?.message || error) + ' Release evidence retained.';
render();
}));
actions.append(rollback);
recovery.append(sha, excerpt, actions);
summary.append(recovery);
}
@ -318,6 +340,57 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
return operation;
}
function prepareRollback(repository, commitSha) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry) return Promise.reject(new Error('Tracked release is unavailable.'));
if (entry.rollback?.state === 'prepared') {
openPull?.(entry.rollback.pull);
return Promise.resolve(entry.rollback.pull);
}
const key = identity(entry);
if (rollingBack.has(key)) return rollingBack.get(key);
const approve = confirmAction || (windowRef?.confirm ? message => windowRef.confirm(message) : () => false);
if (!approve('Prepare a draft rollback PR for ' + entry.key + ' at ' + commitSha.slice(0, 8) + '?')) {
return Promise.resolve(false);
}
const operation = fetchJson(
'api/v1/repos/' + entry.repository.split('/').map(encodeURIComponent).join('/')
+ '/pulls/' + encodeURIComponent(entry.number)
+ '/release-receipt/' + encodeURIComponent(entry.commit_sha) + '/rollback',
{
method: 'POST',
headers: {
Accept: 'application/json',
'Idempotency-Key': 'release-rollback-' + entry.number + '-' + entry.commit_sha,
},
},
).then(result => {
if (result?.rollback_of !== entry.commit_sha || !Number.isInteger(result?.number)) {
throw new Error('Rollback preparation did not match the failed release.');
}
const pull = {
...result,
kind: 'pull',
key: result.repository + '#' + result.number,
state: 'open',
};
entry.rollback = {
state: 'prepared',
number: result.number,
message: 'Draft rollback #' + result.number + ' is ready for review.',
pull,
};
persist();
render();
if (dialog?.open) dialog.close();
openPull?.(pull);
return result;
}).finally(() => rollingBack.delete(key));
rollingBack.set(key, operation);
render();
return operation;
}
async function deleteBranch(repository, commitSha) {
const entry = entries.find(value => identity(value) === repository + '@' + commitSha);
if (!entry?.source_branch || !entry?.source_head_sha || entry.cleanup?.state === 'deleted') {
@ -385,7 +458,7 @@ function createReleaseReceipt({ storage, getLogin, fetchJson, launcher = null, d
schedule();
}
return { capture, restore, refresh, reviewFailure, retryFailure, deleteBranch, dismiss, bind, fetchJson };
return { capture, restore, refresh, reviewFailure, retryFailure, prepareRollback, deleteBranch, dismiss, bind, fetchJson };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createReleaseReceipt;

View File

@ -148,7 +148,7 @@
body:JSON.stringify({body}),
}),
});
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown) => {
root.renderSearchPreviewConversation = (conversation, document, escapeHtml, formatTime, renderMarkdown, actions) => {
const comments = document.querySelector('#search-preview-comments');
const status = document.querySelector('#search-preview-conversation-status');
const retry = document.querySelector('#retry-search-preview-conversation');
@ -162,11 +162,12 @@
new Date(comment.created_at).getTime() > new Date(conversation.reviewedAt).getTime());
const newCount = (conversation.comments || []).filter(isNew).length;
comments.innerHTML = (conversation.comments || []).map(comment =>
'<article class="search-preview-comment' + (isNew(comment) ? ' new-since-review' : '') +
'<article class="search-preview-comment issue-comment' + (isNew(comment) ? ' new-since-review' : '') +
'" data-comment-id="' + Number(comment.id || 0) + '">' +
'<div class="small">' + escapeHtml(comment.author || 'Unknown author') +
(comment.created_at ? ' · ' + escapeHtml(formatTime(comment.created_at)) : '') +
(isNew(comment) ? ' · <strong>New since last review</strong>' : '') + '</div>' +
(actions?.actionHtml?.(comment) || '') +
'<div class="markdown-content">' + renderMarkdown(comment.body || '') + '</div></article>'
).join('');
retry.hidden = conversation.status !== 'error';
@ -181,6 +182,19 @@
(conversation.comments.length === 1 ? ' message' : ' messages') +
(newCount ? ' · ' + newCount + ' new since last review' : '') + '.';
};
root.createSearchPreviewConversationActions = ({hydrator,rootNode,retry,paint,wire}) => {
let latest = null;
const show = conversation => {
latest = conversation;
if (!conversation) {
paint(null, null);
return Promise.resolve(false);
}
return hydrator.show({root:rootNode,state:conversation,paint,retry,wire});
};
retry.addEventListener('click', () => { if (latest) void show(latest); });
return show;
};
root.renderSearchPreviewReply = (state, detail, preview, document) => {
const section = document.querySelector('.search-preview-reply');
const input = document.querySelector('#search-preview-reply');
@ -417,7 +431,27 @@
}
}
const conversationPager = {
snapshot() {
return {...(conversation || {}), comments:(conversation?.comments || []).map(comment => ({...comment}))};
},
replace(comment) {
if (!comment || !Number.isInteger(comment.id) || !conversation) return conversationPager.snapshot();
conversation = {...conversation, comments:(conversation.comments || []).map(existing =>
existing.id === comment.id ? {...comment} : existing)};
publish({status:'ready', item:current, detail:current, conversation});
return conversationPager.snapshot();
},
remove(commentId) {
if (!conversation) return conversationPager.snapshot();
conversation = {...conversation, comments:(conversation.comments || []).filter(comment => comment.id !== commentId)};
publish({status:'ready', item:current, detail:current, conversation});
return conversationPager.snapshot();
},
};
const api = {
commentPager() { return conversationPager; },
hasReplyAttachments() {
return Boolean(hasAttachments?.());
},

View File

@ -33,10 +33,11 @@
}
return async (operation, key, value) => {
const db = await database();
const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite');
const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite');
const records = transaction.objectStore(storeName);
if (operation === 'put') return requestResult(records.put(value));
if (operation === 'delete') return requestResult(records.delete(key));
if (operation === 'list') return requestResult(records.getAll());
return requestResult(records.get(key));
};
}
@ -95,7 +96,9 @@
await transact('delete', id);
return null;
}
await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list });
await transact('put', id, {
id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list,
});
return list;
}
@ -116,6 +119,28 @@
return true;
}
return { save, load, remove };
async function list() {
if (!transact) return [];
const ownerLogin = String(getOwnerLogin() || '').trim();
if (!ownerLogin) return [];
const records = await transact('list');
return (Array.isArray(records) ? records : []).flatMap(record => {
const repository = String(record?.repository || '');
const number = Number(record?.number || 0);
if (record?.version !== 1 || record.ownerLogin !== ownerLogin ||
!['issue', 'pull'].includes(record.kind) || !repository ||
!Number.isInteger(number) || number < 1 ||
!Array.isArray(record.attachments) || !record.attachments.length) return [];
return [{
id:record.id, kind:'photo-reply',
label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos',
repository, number, title:repository + '#' + number,
photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0),
route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search',
}];
}).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id));
}
return { save, load, remove, list };
};
});

View File

@ -232,6 +232,8 @@
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
source_branch_deleted: 'Source branch deleted',
release_rollback_prepared: 'Release rollback prepared',
ci_job_retried: 'CI job retried',
comment_deleted: 'Comment deleted',
pull_review_approved: 'Pull request approved',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',

View File

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

View File

@ -117,16 +117,17 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
}
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
const state = read();
const keepBreak = validBreak(state)?.identity === identity;
state.active_identity = identity;
state.entries = ledger;
state.entries[identity].started_at = running ? now() : null;
state.entries[identity].running = Boolean(running);
state.entries[identity].started_at = running && !keepBreak ? now() : null;
state.entries[identity].running = Boolean(running) && !keepBreak;
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
state.search_interruption = null;
state.detour_interruption = null;
if(running || validBreak(state)?.identity !== identity) state.timed_break=null;
if (!keepBreak) state.timed_break = null;
return write(state);
},
activate(identity) {
@ -488,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
const snapshot = timer.snapshot();
breakView?.render();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-hud]').forEach(element => {
const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])');
element.hidden = !active && !coaching;
});
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});

View File

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

View File

@ -2,76 +2,154 @@ async function loadWorkspace({
document,
window = null,
createLoader = createFeatureLoader,
schedule = callback => setTimeout(callback,750),
schedule = callback => setTimeout(callback, 750),
}) {
let cameOnline = false;
let replayed = false;
let retryInFlight = null;
const captureOnline = () => { cameOnline = true; };
window?.addEventListener('online', captureOnline);
const status = document.querySelector('#my-work-action-status');
const retryButton = document.querySelector('#retry-workspace');
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
const names = ['work-core', 'today-timer', 'planning'];
const urls = Object.fromEntries(names.map(name => [name,
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
]));
const originalUrls = {...urls};
const loader = createLoader({document, urls});
let attempts = 0;
const load = () => {
if (attempts++) Object.keys(urls).forEach(name => {
urls[name] = originalUrls[name] + '?retry=' + attempts;
});
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
const attempts = Object.fromEntries(names.map(name => [name, 0]));
const failed = new Set();
const recoveries = new Map();
let retryInFlight = null;
let resolveWorkspaceReady;
const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; });
let workspaceMarkedReady = false;
const markWorkspaceReady = () => {
if (workspaceMarkedReady) return false;
workspaceMarkedReady = true;
resolveWorkspaceReady(true);
return true;
};
const waitForRecovery = () => new Promise(resolve => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) {
retryButton.hidden = retryButton.disabled = false;
const loadFeature = name => {
attempts[name] += 1;
urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : '');
return loader.load(name);
};
const retryOnce = async name => {
try { return await loadFeature(name); }
catch (_error) {
await new Promise(resolve => schedule(resolve));
return loadFeature(name);
}
const recover = () => {
if (retryInFlight) return retryInFlight;
if (retryButton) retryButton.disabled = true;
retryInFlight = load().then(() => {
window?.removeEventListener('online', recover);
resolve();
}).catch(() => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) retryButton.disabled = false;
}).finally(() => {
retryInFlight = null;
});
return retryInFlight;
};
window?.removeEventListener('online', captureOnline);
window?.addEventListener('online', recover);
retryButton?.addEventListener('click', recover);
});
};
const showRecovery = () => {
if (status) status.textContent = failed.has('work-core') ?
'Workspace unavailable. Reconnect or retry.' :
'Today or planning tools unavailable. My Work is ready; reconnect or retry.';
if (retryButton) retryButton.hidden = retryButton.disabled = false;
};
const hideRecovery = () => {
if (failed.size) return;
if (retryButton) retryButton.hidden = true;
if (status) status.textContent = '';
};
const retryFailed = () => {
if (retryInFlight) return retryInFlight;
if (retryButton) retryButton.disabled = true;
const pending = Array.from(failed);
retryInFlight = Promise.all(pending.map(async name => {
try {
await loadFeature(name);
failed.delete(name);
recoveries.get(name)?.resolve(true);
recoveries.delete(name);
} catch (_error) {}
})).then(() => {
if (failed.size) showRecovery();
else hideRecovery();
}).finally(() => { retryInFlight = null; });
return retryInFlight;
};
retryButton?.addEventListener?.('click', retryFailed);
const handleOnline = () => { cameOnline = true; void retryFailed(); };
window?.addEventListener('online', handleOnline);
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
try {
await load();
} catch {
await new Promise(resolve => schedule(resolve));
try {
await load();
} catch {
await waitForRecovery();
}
await retryOnce('work-core');
} catch (_error) {
failed.add('work-core');
showRecovery();
await new Promise(resolve => recoveries.set('work-core', {resolve}));
}
hideRecovery();
let optionalReady = null;
const hydrateWorkspace = () => {
if (optionalReady) return optionalReady;
let resolveOptional;
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
const optional = ['today-timer', 'planning'].map(async name => {
try {
await retryOnce(name);
return true;
} catch (_error) {
failed.add(name);
showRecovery();
return new Promise(resolve => recoveries.set(name, {resolve}));
}
});
Promise.all(optional).then(() => {
hideRecovery();
resolveOptional(true);
});
return optionalReady;
};
const hydrationSelector = [
'[data-mobile-task]:not([data-mobile-task="queues"])',
'[data-progressive-loading="true"]',
'#app-menu-toggle',
'#work-settings-toggle',
].join(',');
const hydrateForAction = async event => {
const target = event.target?.closest?.(hydrationSelector);
if (!target) return;
event.preventDefault();
event.stopImmediatePropagation();
await hydrateWorkspace();
await workspaceReady;
document.removeEventListener?.('click', hydrateForAction, true);
target.click?.();
};
document.addEventListener?.('click', hydrateForAction, true);
const hash = window?.location?.hash || '';
const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ?
hydrateWorkspace() : Promise.resolve(false);
window?.removeEventListener('online', captureOnline);
if (retryButton) retryButton.hidden = true;
if (status) status.textContent = '';
return {
hydrateWorkspace,
deepLinkReady,
workspaceReady,
markWorkspaceReady,
serviceWorkerReady,
get optionalReady() { return hydrateWorkspace(); },
retryFeature(name) {
if (!failed.has(name)) return Promise.resolve(true);
return retryFailed().then(() => !failed.has(name));
},
replayOnline(callback) {
if (replayed) return;
replayed = true;
window?.removeEventListener('online', captureOnline);
window?.removeEventListener('online', handleOnline);
if (cameOnline) callback();
},
};
}
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
typeof createFeatureLoader === 'function') {
window.stackchainWorkspaceLifecycle = loadWorkspace({document, window});
}
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;

View File

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

View File

@ -32,6 +32,8 @@ class Session:
csrf: str
expires_at: int
idle_expires_at: int | None = None
principal_id: int | None = None
principal_login: str | None = None
@dataclass(frozen=True)
@ -145,6 +147,8 @@ def issue_session(
*,
device_label: str = "This device",
management_id: str | None = None,
principal_id: int | None = None,
principal_login: str | None = None,
) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
@ -165,6 +169,8 @@ def issue_session(
session.expires_at,
device_label=device_label,
management_id=management_id,
principal_id=principal_id,
principal_login=principal_login,
)
return f"{encoded}.{signature}", session
@ -211,6 +217,8 @@ def verify_session_with_reason(
csrf=session.csrf,
expires_at=session.expires_at,
idle_expires_at=getattr(status, "idle_expires_at", None),
principal_id=getattr(status, "principal_id", None),
principal_login=getattr(status, "principal_login", None),
)
)
@ -228,7 +236,11 @@ async def revoke_all_sessions() -> None:
async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
return await asyncio.to_thread(
_session_store().list_active,
session.session_id,
principal_id=session.principal_id,
)
async def session_management_id(session: Session) -> str:
@ -244,11 +256,14 @@ async def managed_session_active(management_id: str) -> bool:
return status == "active"
async def managed_session_statuses(management_ids) -> dict[str, str]:
async def managed_session_statuses(
management_ids, *, expected_principal_id: int | None = None
) -> dict[str, str]:
options = {"idle_timeout_seconds": idle_timeout_seconds()}
if expected_principal_id is not None:
options["expected_principal_id"] = expected_principal_id
return await asyncio.to_thread(
_session_store().managed_statuses,
management_ids,
idle_timeout_seconds=idle_timeout_seconds(),
_session_store().managed_statuses, management_ids, **options
)

35
src/disk_capacity.py Normal file
View File

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

View File

@ -21,6 +21,10 @@ COMMONJS_BROWSER_BRANCH = re.compile(
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"work-core": (
"static/my-work.js", "static/progressive-my-work.js", "static/progressive-mobile-dock.js",
"static/unfiled-captures.js", "static/progressive-capture.js",
),
"comment-actions": ("static/comment-actions.js",),
"issue-capture": (
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
@ -35,18 +39,18 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"planning": (
"static/plan-today.js", "static/plan-today-readiness.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js",
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js", "static/search-preview.js",
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-recent-work.js", "static/mobile-queue-priority.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/dashboard.js",
),
}
CACHE_DECLARATION = re.compile(
@ -122,7 +126,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
)
workspace_preload = (
f'<link rel="preload" as="script" '
f'href="{feature_bundles["today-timer"].runtime_name}">'
f'href="{feature_bundles["work-core"].runtime_name}">'
)
dashboard_html = dashboard_html.replace(
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
@ -136,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
optional_features = feature_bundles
# The workspace hydrator fetches these large chunks only when a route or action
# needs them; warming them here would defeat demand loading on every visit.
optional_features = {
name: bundle for name, bundle in feature_bundles.items()
if name not in {"today-timer", "planning"}
}
demand_features = {
name: bundle for name, bundle in feature_bundles.items()
if name in {"today-timer", "planning"}
}
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
@ -147,6 +160,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = worker.replace(
"const DEMAND_FEATURES = [\n",
"const DEMAND_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
)

View File

@ -1,4 +1,5 @@
import asyncio
import base64
import os
import re
import shlex
@ -12,6 +13,9 @@ GITEA_URL = os.getenv("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
REVIEW_DIFF_MAX_BYTES = 64 * 1024
REVIEW_DIFF_MAX_LINES = 400
ROLLBACK_MAX_FILES = 20
ROLLBACK_MAX_FILE_BYTES = 256 * 1024
ROLLBACK_MAX_TOTAL_BYTES = 1024 * 1024
AVAILABLE_ISSUE_PAGE_CONCURRENCY = 3
_client: "GiteaTransport | None" = None
@ -181,6 +185,14 @@ class PullCreateConflictError(ValueError):
"""Raised when the selected source branch changed before pull creation."""
class ReleaseRollbackConflictError(ValueError):
"""Raised when current target content no longer permits an exact rollback."""
class ReleaseRollbackUnsupportedError(ValueError):
"""Raised when a merge cannot be represented as one bounded text rollback."""
class SourceBranchChangedError(ValueError):
"""Raised before deletion when a merged source branch has advanced."""
@ -1369,6 +1381,86 @@ def _normalize_issue_comment(comment: dict) -> dict:
}
COMMENT_REACTIONS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes")
async def _conversation_comment(repository: str, number: int, comment_id: int) -> dict:
comment = await fetch(f"repos/{repository}/issues/comments/{comment_id}")
expected_issue_path = f"repos/{repository}/issues/{number}"
issue_url = comment.get("issue_url") if isinstance(comment, dict) else None
safe_issue_url = _safe_gitea_web_url(issue_url)
web_path = urlsplit(safe_issue_url).path.rstrip("/") if safe_issue_url else ""
if (
not isinstance(comment, dict)
or (
_gitea_api_path(issue_url) != expected_issue_path
and not web_path.endswith(f"/{repository}/issues/{number}")
)
):
raise CommentMutationForbiddenError("Comment is not part of this conversation")
return comment
async def comment_reactions(repository: str, number: int, comment_id: int) -> dict:
user = await current_user()
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Authenticated Gitea user is unavailable")
await _conversation_comment(repository, number, comment_id)
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
response = await _get_client().get(path, headers=_auth())
response.raise_for_status()
payload = response.json()
counts: dict[str, int] = {}
selected: set[str] = set()
for reaction in payload if isinstance(payload, list) else []:
content = reaction.get("content") if isinstance(reaction, dict) else None
author = reaction.get("user") if isinstance(reaction, dict) else None
author_login = author.get("login") if isinstance(author, dict) else None
if content not in COMMENT_REACTIONS or not isinstance(author_login, str) or not author_login:
continue
counts[content] = counts.get(content, 0) + 1
if author_login == login:
selected.add(content)
return {
"comment_id": comment_id,
"reactions": [
{"content": content, "count": counts[content], "selected": content in selected}
for content in COMMENT_REACTIONS
if counts.get(content)
],
}
async def set_comment_reaction(
repository: str, number: int, comment_id: int, content: str, active: bool
) -> dict:
if content not in COMMENT_REACTIONS:
raise ValueError("Unsupported comment reaction")
before = await comment_reactions(repository, number, comment_id)
selected = any(
item["content"] == content and item["selected"]
for item in before["reactions"]
)
path = f"/api/v1/repos/{repository}/issues/comments/{comment_id}/reactions"
if selected != active:
client = _get_client()
response = (
await client.post(path, headers=_auth(), json={"content": content})
if active
else await client.delete(path, headers=_auth(), json={"content": content})
)
response.raise_for_status()
confirmed = await comment_reactions(repository, number, comment_id)
confirmed_selected = any(
item["content"] == content and item["selected"]
for item in confirmed["reactions"]
)
if confirmed_selected != active:
raise ValueError("Gitea did not confirm the requested reaction state")
return confirmed
async def comment_on_issue(repository: str, number: int, body: str) -> dict:
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/comments",
@ -2209,6 +2301,231 @@ async def reopen_authored_pull(
)
async def authored_pull_feedback_file(
repository: str, number: int, path: str, expected_head_sha: str
) -> dict:
"""Load one bounded UTF-8 file from an authored same-repository pull head."""
if (
not path or path.startswith("/") or "\\" in path
or any(part in {"", ".", ".."} for part in path.split("/"))
):
raise IssueNotAvailableError("File path is not eligible")
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
if (
pull.get("state") != "open" or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
or head_repo.get("full_name") != repository
or base_repo.get("full_name") != repository
):
raise IssueNotAvailableError("Pull request state changed")
response = await _get_client().get(
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
headers=_auth(), params={"ref": expected_head_sha},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise IssueNotAvailableError("File is not editable text")
encoded = payload.get("content")
if (
payload.get("type") != "file" or payload.get("encoding") != "base64"
or not isinstance(encoded, str) or not isinstance(payload.get("sha"), str)
):
raise IssueNotAvailableError("File is not editable text")
try:
raw = base64.b64decode(encoded, validate=True)
content = raw.decode("utf-8")
except (ValueError, UnicodeDecodeError):
raise IssueNotAvailableError("File is not editable text") from None
if len(raw) > 128 * 1024 or "\x00" in content:
raise IssueNotAvailableError("File is not editable text")
return {
"repository": repository, "number": number, "path": path,
"head_sha": expected_head_sha, "blob_sha": payload["sha"],
"content": content,
}
async def commit_authored_pull_feedback_fix(
repository: str,
number: int,
path: str,
content: str,
message: str,
expected_head_sha: str,
expected_blob_sha: str,
) -> dict:
"""Commit and verify one race-guarded text-file fix on an authored pull."""
encoded = content.encode("utf-8")
message = message.strip()
if len(encoded) > 128 * 1024 or "\x00" in content or not message or len(message) > 120:
raise IssueNotAvailableError("Feedback fix is outside the editable bounds")
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
branch = head.get("ref")
if (
pull.get("state") != "open" or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
or head_repo.get("full_name") != repository
or base_repo.get("full_name") != repository
or not isinstance(branch, str) or not branch
):
raise IssueNotAvailableError("Pull request state changed")
current = await authored_pull_feedback_file(
repository, number, path, expected_head_sha
)
if current["blob_sha"] != expected_blob_sha or current["content"] == content:
raise IssueNotAvailableError("File changed or has no new content")
response = await _get_client().put(
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
headers=_auth(),
json={
"branch": branch, "sha": expected_blob_sha, "message": message,
"content": base64.b64encode(encoded).decode(),
},
)
if response.status_code in {409, 422}:
raise IssueNotAvailableError("Pull request or file changed")
response.raise_for_status()
confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}")
confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {}
new_head_sha = confirmed_head.get("sha")
if not isinstance(new_head_sha, str) or not new_head_sha or new_head_sha == expected_head_sha:
raise ValueError("Gitea did not confirm a new pull request head")
confirmed = await authored_pull_feedback_file(
repository, number, path, new_head_sha
)
if confirmed["content"] != content:
raise ValueError("Gitea did not confirm the committed file content")
return {
"repository": repository, "number": number, "path": path,
"previous_head_sha": expected_head_sha, "head_sha": new_head_sha,
"blob_sha": confirmed["blob_sha"], "message": message,
}
async def commit_authored_pull_feedback_batch(
repository: str,
number: int,
files: list[dict],
suggestion_count: int,
message: str,
expected_head_sha: str,
) -> dict:
"""Commit reviewed replacements across bounded files as one verified commit."""
message = message.strip()
if (
not 2 <= suggestion_count <= 8
or not 1 <= len(files) <= 8
or len(files) > suggestion_count
or not message
or len(message) > 120
):
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
paths = [file.get("path", "") for file in files]
if len(set(paths)) != len(paths):
raise IssueNotAvailableError("Feedback batch contains duplicate files")
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
head = pull.get("head") if isinstance(pull.get("head"), dict) else {}
base = pull.get("base") if isinstance(pull.get("base"), dict) else {}
head_repo = head.get("repo") if isinstance(head.get("repo"), dict) else {}
base_repo = base.get("repo") if isinstance(base.get("repo"), dict) else {}
branch = head.get("ref")
if (
pull.get("state") != "open"
or pull.get("merged") is True
or author.get("login", "").casefold() != login.casefold()
or head.get("sha") != expected_head_sha
or head_repo.get("full_name") != repository
or base_repo.get("full_name") != repository
or not isinstance(branch, str)
or not branch
):
raise IssueNotAvailableError("Pull request state changed")
operations = []
submitted: dict[str, str] = {}
total_bytes = 0
for file in files:
path = file.get("path", "")
content = file.get("content", "")
expected_blob_sha = file.get("expected_blob_sha", "")
if not isinstance(content, str):
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
encoded = content.encode("utf-8")
total_bytes += len(encoded)
if len(encoded) > 128 * 1024 or total_bytes > 512 * 1024 or "\x00" in content:
raise IssueNotAvailableError("Feedback batch is outside the editable bounds")
current = await authored_pull_feedback_file(
repository, number, path, expected_head_sha
)
if current["blob_sha"] != expected_blob_sha or current["content"] == content:
raise IssueNotAvailableError("File changed or has no new content")
operations.append({
"operation": "update",
"path": path,
"sha": expected_blob_sha,
"content": base64.b64encode(encoded).decode(),
})
submitted[path] = content
response = await _get_client().post(
f"/api/v1/repos/{repository}/contents",
headers=_auth(),
json={"branch": branch, "message": message, "files": operations},
)
if response.status_code in {409, 422}:
raise IssueNotAvailableError("Pull request or file changed")
response.raise_for_status()
payload = response.json()
commit = payload.get("commit") if isinstance(payload, dict) else None
mutation_sha = commit.get("sha") if isinstance(commit, dict) else None
if not isinstance(mutation_sha, str) or not mutation_sha:
raise ValueError("Gitea did not confirm the feedback batch commit")
confirmed_pull = await fetch(f"repos/{repository}/pulls/{number}")
confirmed_head = confirmed_pull.get("head") if isinstance(confirmed_pull, dict) and isinstance(confirmed_pull.get("head"), dict) else {}
new_head_sha = confirmed_head.get("sha")
if new_head_sha != mutation_sha or new_head_sha == expected_head_sha:
raise ValueError("Gitea did not confirm a new pull request head")
for path, content in submitted.items():
confirmed = await authored_pull_feedback_file(
repository, number, path, new_head_sha
)
if confirmed["content"] != content:
raise ValueError("Gitea did not confirm every committed file")
return {
"repository": repository,
"number": number,
"paths": paths,
"previous_head_sha": expected_head_sha,
"head_sha": new_head_sha,
"suggestion_count": suggestion_count,
"message": message,
}
async def update_authored_pull_branch(
repository: str, number: int, expected_head_sha: str
) -> dict:
@ -3113,6 +3430,193 @@ async def can_recover_merged_release(
)
async def prepare_release_rollback(
repository: str, number: int, commit_sha: str
) -> dict:
"""Create one bounded reverse commit and draft pull for an exact merged pull."""
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
author = pull.get("user") if isinstance(pull.get("user"), dict) else {}
if not (
pull.get("state") == "closed"
and pull.get("merged") is True
and pull.get("merge_commit_sha") == commit_sha
and (
author.get("login", "").casefold() == login.casefold()
or _login_in_users(login, pull.get("assignees"))
)
):
raise IssueNotAvailableError("merged pull request not found")
access = await repository_access(repository)
permissions = access.get("permissions", {}) if isinstance(access, dict) else {}
default_branch = access.get("default_branch") if isinstance(access, dict) else None
if permissions.get("push") is not True or not isinstance(default_branch, str):
raise IssueNotAvailableError("writable repository not found")
commit_response = await _get_client().get(
f"/api/v1/repos/{repository}/git/commits/{commit_sha}", headers=_auth()
)
commit_response.raise_for_status()
merge_commit = commit_response.json()
parents = merge_commit.get("parents") if isinstance(merge_commit, dict) else None
files = merge_commit.get("files") if isinstance(merge_commit, dict) else None
if (
merge_commit.get("sha") != commit_sha
or not isinstance(parents, list)
or len(parents) < 2
or not isinstance(parents[0], dict)
or not isinstance(parents[0].get("sha"), str)
or not isinstance(files, list)
or not 1 <= len(files) <= ROLLBACK_MAX_FILES
):
raise ReleaseRollbackUnsupportedError("merge is not a bounded merge commit")
parent_sha = parents[0]["sha"]
branch = f"{login}/rollback-{number}-{commit_sha[:12]}"
branch_path = quote(branch, safe="")
branch_response = await _get_client().get(
f"/api/v1/repos/{repository}/branches/{branch_path}", headers=_auth()
)
if branch_response.status_code == 404:
async def content(path: str, ref: str) -> tuple[bytes, str] | None:
response = await _get_client().get(
f"/api/v1/repos/{repository}/contents/{quote(path, safe='/')}",
headers=_auth(),
params={"ref": ref},
)
if response.status_code == 404:
return None
response.raise_for_status()
value = response.json()
if (
not isinstance(value, dict)
or value.get("type") != "file"
or value.get("encoding") != "base64"
or not isinstance(value.get("content"), str)
or not isinstance(value.get("sha"), str)
):
raise ReleaseRollbackUnsupportedError("rollback contains a non-file entry")
try:
raw = base64.b64decode(value["content"], validate=True)
raw.decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
raise ReleaseRollbackUnsupportedError(
"rollback contains binary or invalid content"
) from exc
if len(raw) > ROLLBACK_MAX_FILE_BYTES:
raise ReleaseRollbackUnsupportedError("rollback file is too large")
return raw, value["sha"]
operations = []
total_bytes = 0
for changed in files:
filename = changed.get("filename") if isinstance(changed, dict) else None
status = changed.get("status") if isinstance(changed, dict) else None
if (
not isinstance(filename, str)
or not filename
or filename.startswith("/")
or ".." in filename.split("/")
or status not in {"added", "modified", "removed"}
):
raise ReleaseRollbackUnsupportedError("rollback contains an unsupported change")
merged = await content(filename, commit_sha)
parent = await content(filename, parent_sha)
current = await content(filename, default_branch)
if (
(current is None) != (merged is None)
or (
current is not None
and merged is not None
and current[0] != merged[0]
)
):
raise ReleaseRollbackConflictError(
f"{filename} changed after the failed release"
)
if status == "added" and merged is not None and parent is None:
operations.append({
"operation": "delete", "path": filename, "sha": current[1],
})
elif status == "removed" and merged is None and parent is not None:
operations.append({
"operation": "create", "path": filename,
"content": base64.b64encode(parent[0]).decode(),
})
total_bytes += len(parent[0])
elif status == "modified" and merged is not None and parent is not None:
operations.append({
"operation": "update", "path": filename, "sha": current[1],
"content": base64.b64encode(parent[0]).decode(),
})
total_bytes += len(parent[0])
else:
raise ReleaseRollbackUnsupportedError("change metadata did not match content")
if total_bytes > ROLLBACK_MAX_TOTAL_BYTES:
raise ReleaseRollbackUnsupportedError("rollback content is too large")
mutation = await _get_client().post(
f"/api/v1/repos/{repository}/contents",
headers=_auth(),
json={
"branch": default_branch,
"new_branch": branch,
"message": f"Revert {commit_sha} from pull #{number}",
"files": operations,
},
)
if mutation.status_code in {409, 422}:
reconciled = await _get_client().get(
f"/api/v1/repos/{repository}/branches/{branch_path}", headers=_auth()
)
reconciled.raise_for_status()
branch_payload = reconciled.json()
branch_commit = (
branch_payload.get("commit") if isinstance(branch_payload, dict) else None
)
rollback_sha = (
branch_commit.get("id") if isinstance(branch_commit, dict) else None
)
if not isinstance(rollback_sha, str):
raise ValueError("Gitea did not confirm the concurrent rollback branch")
else:
mutation.raise_for_status()
payload = mutation.json()
created_commit = payload.get("commit") if isinstance(payload, dict) else None
rollback_sha = created_commit.get("sha") if isinstance(created_commit, dict) else None
if not isinstance(rollback_sha, str):
raise ValueError("Gitea did not confirm rollback commit creation")
else:
branch_response.raise_for_status()
branch_payload = branch_response.json()
branch_commit = branch_payload.get("commit") if isinstance(branch_payload, dict) else None
rollback_sha = branch_commit.get("id") if isinstance(branch_commit, dict) else None
if not isinstance(rollback_sha, str):
raise ValueError("Gitea did not confirm the rollback branch")
title = f"Rollback #{number}: {pull.get('title', 'failed release')}"
body = (
f"Rollback of #{number} at `{commit_sha}` after failed release checks.\n\n"
"This draft reverses only files that still matched the failed merge."
)
result = await create_pull(
repository,
head=branch,
base=default_branch,
title=title,
body=body,
draft=True,
expected_head_sha=rollback_sha,
)
return {
**result,
"rollback_of": commit_sha,
"files_changed": len(files),
}
async def pull_workspace_capabilities(repository: str, number: int) -> dict[str, bool]:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
@ -3221,6 +3725,20 @@ async def pull_completion_detail(
}
def _single_line_suggestion(body: str, line: object) -> dict | None:
if not isinstance(line, int) or line <= 0:
return None
blocks = re.findall(r"(?ms)^```suggestion[ \t]*\r?\n(.*?)\r?\n```[ \t]*$", body)
if len(blocks) != 1:
return None
content = blocks[0].replace("\r\n", "\n")
if not content or "\x00" in content or len(content.encode("utf-8")) > 4096:
return None
if len(content.splitlines()) > 20:
return None
return {"line": line, "content": content}
def _normalize_review_comments(comments: object) -> list[dict]:
normalized = []
for comment in (comments if isinstance(comments, list) else [])[:100]:
@ -3237,6 +3755,9 @@ def _normalize_review_comments(comments: object) -> list[dict]:
position = comment.get("new_position") or comment.get("old_position")
if isinstance(position, int) and position > 0:
item["line"] = position
suggestion = _single_line_suggestion(body, position)
if suggestion is not None:
item["suggestion"] = suggestion
normalized.append(item)
if len(normalized) == 20:
break

400
src/human_gate_store.py Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@ class StoredPasskey:
device_label: str
management_id: str
created_at: int
principal_id: int
class PasskeyStore:
@ -50,10 +51,19 @@ class PasskeyStore:
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
created_at INTEGER NOT NULL,
principal_id INTEGER
)
"""
)
credential_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(passkey_credentials)")
}
if "principal_id" not in credential_columns:
connection.execute(
"ALTER TABLE passkey_credentials ADD COLUMN principal_id INTEGER"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_challenges (
@ -172,12 +182,14 @@ class PasskeyStore:
sign_count: int,
device_label: str,
management_id: str,
principal_id: int,
) -> None:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"device_label, management_id, created_at, principal_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
@ -185,41 +197,48 @@ class PasskeyStore:
device_label,
management_id,
int(self.clock()),
principal_id,
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def all(self) -> list[StoredPasskey]:
def all(self, *, principal_id: int) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials ORDER BY created_at DESC"
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
"created_at, principal_id FROM passkey_credentials "
"WHERE principal_id = ? ORDER BY created_at DESC",
(principal_id,),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return [StoredPasskey(*row) for row in rows]
def get(self, credential_id: bytes) -> StoredPasskey | None:
def get(self, credential_id: bytes, *, principal_id: int) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
"created_at, principal_id FROM passkey_credentials "
"WHERE credential_id = ? AND principal_id = ?",
(credential_id, principal_id),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def get_management_id(self, management_id: str) -> StoredPasskey | None:
def get_management_id(
self, management_id: str, *, principal_id: int
) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
"FROM passkey_credentials WHERE management_id = ?",
(management_id,),
"SELECT credential_id, public_key, sign_count, device_label, management_id, "
"created_at, principal_id FROM passkey_credentials "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
@ -303,13 +322,14 @@ class PasskeyStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_device_access(self, management_id: str) -> bool:
def revoke_device_access(self, management_id: str, *, principal_id: int) -> bool:
"""Atomically remove one active session, its grants, and its linked passkey."""
try:
with self._connect() as connection:
session = connection.execute(
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
(management_id,),
"SELECT session_hash FROM active_sessions "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
).fetchone()
if session is None:
return False
@ -317,22 +337,44 @@ class PasskeyStore:
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
)
connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
"DELETE FROM passkey_credentials "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
)
cursor = connection.execute(
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
"DELETE FROM active_sessions "
"WHERE management_id = ? AND principal_id = ?",
(management_id, principal_id),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all_access(self) -> None:
"""Atomically remove every passkey, challenge, grant, and active session."""
def revoke_all_access(self, *, principal_id: int) -> list[str]:
"""Atomically remove one principal's passkeys, grants, and active sessions."""
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
connection.execute("DELETE FROM step_up_grants")
connection.execute("DELETE FROM active_sessions")
management_ids = [
row[0]
for row in connection.execute(
"SELECT management_id FROM active_sessions "
"WHERE principal_id = ? ORDER BY management_id",
(principal_id,),
).fetchall()
]
connection.execute(
"DELETE FROM passkey_credentials WHERE principal_id = ?",
(principal_id,),
)
connection.execute(
"DELETE FROM step_up_grants WHERE session_hash IN ("
"SELECT session_hash FROM active_sessions WHERE principal_id = ?)",
(principal_id,),
)
connection.execute(
"DELETE FROM active_sessions WHERE principal_id = ?",
(principal_id,),
)
return management_ids
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc

View File

@ -110,6 +110,7 @@ async def dispatch_following_changes(
lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
now: float | None = None,
) -> int:
"""Notify opted-in devices once for each privacy-safe Following change set."""
if not configuration.enabled:
@ -119,13 +120,15 @@ async def dispatch_following_changes(
store.acquire_dispatch_lease,
owner,
channel="following",
now=time.time(),
now=time.time() if now is None else now,
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not acquired:
return 0
try:
devices = await asyncio.to_thread(store.following_notification_devices)
devices = await asyncio.to_thread(
store.following_notification_devices, now=now
)
if not devices:
return 0
snapshot = await following()
@ -174,17 +177,25 @@ async def dispatch_following_changes(
device for device in pending if statuses.get(device.session_id) == "active"
]
count = min(len(changed), 50)
payload = json.dumps({
"title": f"{count} watched item{'s' if count != 1 else ''} changed",
"body": "Open Following to review the latest activity.",
"route": "#/my-work/following",
"tag": f"stackchain-following-{fingerprint[:16]}",
"following_count": count,
}, separators=(",", ":"))
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
payload = json.dumps({
"title": (
f"{count} watched update{'s' if count != 1 else ''} while alerts were paused"
if device.catch_up
else f"{count} watched item{'s' if count != 1 else ''} changed"
),
"body": "Open Following to review the latest activity.",
"route": "#/my-work/following",
"tag": (
"stackchain-following-catch-up"
if device.catch_up
else f"stackchain-following-{fingerprint[:16]}"
),
"following_count": count,
}, separators=(",", ":"))
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
@ -229,6 +240,104 @@ async def dispatch_following_changes(
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="following")
async def dispatch_human_gate_changes(
store: PushSubscriptionStore,
configuration: PushConfiguration,
pending_gates: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
lease_seconds: float = 60.0,
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
now: float | None = None,
) -> int:
"""Notify opted-in active devices when the pending Human Gate count changes."""
if not configuration.enabled:
return 0
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="human-gates",
now=time.time() if now is None else now,
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not acquired:
return 0
try:
devices = await asyncio.to_thread(store.human_gate_notification_devices, now=now)
if not devices:
return 0
snapshot = await pending_gates()
if not isinstance(snapshot, dict) or snapshot.get("complete") is False:
return 0
count = snapshot.get("count")
if not isinstance(count, int) or isinstance(count, bool) or count < 0:
return 0
count = min(count, 50)
pending = [device for device in devices if device.delivered_count != count]
if not pending:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses([device.session_id for device in pending])
except Exception:
return 0
for device in pending:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
pending = [device for device in pending if statuses.get(device.session_id) == "active"]
if count == 0:
await asyncio.gather(*(
asyncio.to_thread(store.mark_human_gate_delivered, device.session_id, 0)
for device in pending
))
return 0
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
payload = json.dumps({
"title": f"{count} release decision{' is' if count == 1 else 's are'} waiting",
"body": f"Open Human Gates to review {'it' if count == 1 else 'them'}.",
"route": "#/my-work/human-gates",
"tag": f"stackchain-human-gates-{count}",
"human_gate_count": count,
}, separators=(",", ":"))
try:
operation = (
send(device.subscription, payload)
if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
await asyncio.to_thread(store.delete_session, device.session_id)
else:
await asyncio.to_thread(
store.mark_delivery_failed, device.session_id, "human-gates",
_delivery_failure_reason(error),
)
return 0
await asyncio.to_thread(
store.mark_delivery_succeeded, device.session_id, "human-gates"
)
await asyncio.to_thread(
store.mark_human_gate_delivered, device.session_id, count
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device in pending), return_exceptions=True
)
return sum(result for result in results if isinstance(result, int))
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="human-gates")
async def dispatch_unread_updates(
store: PushSubscriptionStore,
configuration: PushConfiguration,
@ -241,6 +350,7 @@ async def dispatch_unread_updates(
max_concurrency: int = 8,
max_individual_notifications: int = 3,
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
now: float | None = None,
) -> int:
if not configuration.enabled:
return 0
@ -249,7 +359,7 @@ async def dispatch_unread_updates(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
now=time.time() if now is None else now,
lease_seconds=lease_seconds,
)
if not acquired:
@ -265,7 +375,9 @@ async def dispatch_unread_updates(
}
unread_count = min(len(thread_revisions), 9999)
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
deliveries = await asyncio.to_thread(
store.claim_unseen, thread_revisions, now=now
)
if session_statuses is not None:
try:
statuses = await session_statuses(
@ -306,10 +418,10 @@ async def dispatch_unread_updates(
for thread_revision in delivery.thread_revisions
if thread_revision not in digest_pending
)
individual_revisions = new_revisions[
individual_revisions = () if delivery.catch_up else new_revisions[
:max(0, max_individual_notifications)
]
overflow_revisions = (
overflow_revisions = delivery.thread_revisions if delivery.catch_up else (
delivery.digest_revisions
+ new_revisions[len(individual_revisions):]
)
@ -385,10 +497,22 @@ async def dispatch_unread_updates(
return count
payload = json.dumps(
{
"title": f"{len(overflow_revisions)} new work updates",
"body": "Tap to review them in Stackchain.",
"title": (
f"{len(overflow_revisions)} updates while alerts were paused"
if delivery.catch_up
else f"{len(overflow_revisions)} new work updates"
),
"body": (
"Open Updates to catch up in Stackchain."
if delivery.catch_up
else "Tap to review them in Stackchain."
),
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"tag": (
"stackchain-update-catch-up"
if delivery.catch_up
else "stackchain-update-digest"
),
"update_count": len(overflow_revisions),
"unread_count": unread_count,
},

View File

@ -7,7 +7,9 @@ import sqlite3
import time
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import datetime, timezone as datetime_timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from src.private_state import connect_private_sqlite
from src.state_encryption import (
@ -22,6 +24,7 @@ class PushDelivery:
subscription: dict
thread_revisions: tuple[tuple[int, str], ...]
digest_revisions: tuple[tuple[int, str], ...] = ()
catch_up: bool = False
@property
def thread_ids(self) -> tuple[int, ...]:
@ -57,6 +60,15 @@ class FollowingNotificationDevice:
session_id: str
subscription: dict
delivered_fingerprint: str | None
catch_up: bool = False
@dataclass(frozen=True)
class HumanGateNotificationDevice:
session_id: str
subscription: dict
delivered_count: int
catch_up: bool = False
class DisabledPushSubscriptionStore:
@ -91,10 +103,19 @@ class DisabledPushSubscriptionStore:
def following_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
def human_gate_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def quiet_hours(self, session_id: str) -> dict:
return {"enabled": False, "start": "22:00", "end": "07:00", "timezone": "UTC"}
def following_notification_devices(self, *, now: float | None = None) -> list[FollowingNotificationDevice]:
return []
def claim_unseen(self, thread_revisions) -> list[PushDelivery]:
def human_gate_notification_devices(self, *, now: float | None = None) -> list[HumanGateNotificationDevice]:
return []
def claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
return []
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
@ -112,6 +133,9 @@ class DisabledPushSubscriptionStore:
def delete_session(self, *args, **kwargs) -> None:
return None
def delete_sessions(self, *args, **kwargs) -> None:
return None
def delete_all(self, *args, **kwargs) -> None:
return None
@ -133,9 +157,18 @@ class DisabledPushSubscriptionStore:
def set_following_preferences(self, *args, **kwargs) -> None:
return None
def set_human_gate_preferences(self, *args, **kwargs) -> None:
return None
def set_quiet_hours(self, *args, **kwargs) -> None:
return None
def mark_following_delivered(self, *args, **kwargs) -> None:
return None
def mark_human_gate_delivered(self, *args, **kwargs) -> None:
return None
def reconcile_unread(self, *args, **kwargs) -> None:
return None
@ -183,6 +216,16 @@ def _revisions(
return tuple(sorted(normalized.items()))
def _inside_quiet_hours(*, now: float, start: str, end: str, timezone: str) -> bool:
local = datetime.fromtimestamp(now, tz=datetime_timezone.utc).astimezone(ZoneInfo(timezone))
minute = local.hour * 60 + local.minute
start_minute = int(start[:2]) * 60 + int(start[3:])
end_minute = int(end[:2]) * 60 + int(end[3:])
if start_minute < end_minute:
return start_minute <= minute < end_minute
return minute >= start_minute or minute < end_minute
class PushSubscriptionStore:
"""Durable, device-bound Web Push subscriptions and delivery deduplication."""
@ -262,6 +305,23 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_human_gate_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
delivered_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_quiet_hours (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
start_time TEXT NOT NULL DEFAULT '22:00',
end_time TEXT NOT NULL DEFAULT '07:00',
timezone TEXT NOT NULL DEFAULT 'UTC',
suppressed INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
@ -385,6 +445,17 @@ class PushSubscriptionStore:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,))
def delete_sessions(self, session_ids) -> None:
requested = set(session_ids)
if not requested:
return
placeholders = ",".join("?" for _ in requested)
with self._connect() as connection:
connection.execute(
f"DELETE FROM push_subscriptions WHERE session_id IN ({placeholders})",
tuple(requested),
)
def delete_all(self) -> None:
with self._connect() as connection:
connection.execute("DELETE FROM push_subscriptions")
@ -612,20 +683,81 @@ class PushSubscriptionStore:
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
def set_human_gate_preferences(self, session_id: str, *, enabled: bool) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_human_gate_preferences(session_id, enabled)
VALUES (?, ?)
ON CONFLICT(session_id) DO UPDATE SET enabled = excluded.enabled""",
(session_id, int(enabled)),
)
def human_gate_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT enabled FROM push_human_gate_preferences WHERE session_id = ?",
(session_id,),
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def set_quiet_hours(
self, session_id: str, *, enabled: bool, start: str, end: str, timezone: str
) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_quiet_hours(
session_id, enabled, start_time, end_time, timezone
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
enabled = excluded.enabled,
start_time = excluded.start_time,
end_time = excluded.end_time,
timezone = excluded.timezone,
suppressed = CASE WHEN excluded.enabled = 1 THEN suppressed ELSE 0 END""",
(session_id, int(enabled), start, end, timezone),
)
def quiet_hours(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"""SELECT enabled, start_time, end_time, timezone
FROM push_quiet_hours WHERE session_id = ?""",
(session_id,),
).fetchone()
return {
"enabled": bool(row[0]) if row else False,
"start": row[1] if row else "22:00",
"end": row[2] if row else "07:00",
"timezone": row[3] if row else "UTC",
}
def following_notification_devices(
self, *, now: float | None = None
) -> list[FollowingNotificationDevice]:
checked_at = time.time() if now is None else now
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint
"""SELECT s.session_id, s.subscription_json, p.delivered_fingerprint,
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
FROM push_subscriptions s
JOIN push_following_preferences p ON p.session_id = s.session_id
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
return [
FollowingNotificationDevice(
row[0], self._open_subscription(row[0], row[1]), row[2]
)
for row in rows
]
devices = []
for row in rows:
if row[3] and _inside_quiet_hours(
now=checked_at, start=row[4], end=row[5], timezone=row[6]
):
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
(row[0],),
)
continue
devices.append(FollowingNotificationDevice(
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
))
return devices
def mark_following_delivered(self, session_id: str, fingerprint: str) -> None:
with self._connect() as connection:
@ -634,9 +766,54 @@ class PushSubscriptionStore:
WHERE session_id = ? AND enabled = 1""",
(fingerprint, session_id),
)
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
(session_id,),
)
def human_gate_notification_devices(
self, *, now: float | None = None
) -> list[HumanGateNotificationDevice]:
checked_at = time.time() if now is None else now
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.delivered_count,
q.enabled, q.start_time, q.end_time, q.timezone, q.suppressed
FROM push_subscriptions s
JOIN push_human_gate_preferences p ON p.session_id = s.session_id
LEFT JOIN push_quiet_hours q ON q.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
devices = []
for row in rows:
if row[3] and _inside_quiet_hours(
now=checked_at, start=row[4], end=row[5], timezone=row[6]
):
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
(row[0],),
)
continue
devices.append(HumanGateNotificationDevice(
row[0], self._open_subscription(row[0], row[1]), row[2], bool(row[7])
))
return devices
def mark_human_gate_delivered(self, session_id: str, count: int) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_human_gate_preferences SET delivered_count = ?
WHERE session_id = ? AND enabled = 1""",
(count, session_id),
)
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
(session_id,),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
*, now: float | None = None,
) -> list[PushDelivery]:
candidates = _revisions(thread_revisions)
if not candidates:
@ -647,6 +824,21 @@ class PushSubscriptionStore:
).fetchall()
deliveries = []
for session_id, encoded in rows:
quiet = connection.execute(
"""SELECT enabled, start_time, end_time, timezone, suppressed
FROM push_quiet_hours WHERE session_id = ?""",
(session_id,),
).fetchone()
catch_up = bool(quiet and quiet[4])
if quiet and quiet[0] and _inside_quiet_hours(
now=time.time() if now is None else now,
start=quiet[1], end=quiet[2], timezone=quiet[3],
):
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 1 WHERE session_id = ?",
(session_id,),
)
continue
delivered = {
row[0]: row[1]
for row in connection.execute(
@ -700,6 +892,7 @@ class PushSubscriptionStore:
self._open_subscription(session_id, encoded),
unseen,
tuple(digest_revisions),
catch_up,
)
)
return deliveries
@ -770,3 +963,7 @@ class PushSubscriptionStore:
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
((session_id, thread_id) for thread_id, _revision in values),
)
connection.execute(
"UPDATE push_quiet_hours SET suppressed = 0 WHERE session_id = ?",
(session_id,),
)

100
src/queue_priority_store.py Normal file
View File

@ -0,0 +1,100 @@
"""Encrypted, revisioned mobile routine queue priority."""
import sqlite3
from pathlib import Path
from src.private_state import connect_private_sqlite
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_config
DEFAULT_QUEUE_ORDER = (
"attention", "today", "update", "agenda", "following", "authored", "filed", "later", "draft",
)
class QueuePriorityConflict(ValueError):
"""Raised when a stale client attempts to replace the queue order."""
def __init__(self, snapshot: dict):
super().__init__("queue priority changed on another device")
self.snapshot = snapshot
class QueuePriorityStore:
def __init__(self, path: str | Path, *, timeout: float = 1.0, encryption_key: bytes | None = None):
self.path = Path(path)
self.timeout = timeout
self._cipher = PrivateStateCipher(
encryption_key if encryption_key is not None else private_state_encryption_config(),
store="queue-priority",
)
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"CREATE TABLE IF NOT EXISTS queue_priorities ("
"login TEXT PRIMARY KEY, revision INTEGER NOT NULL, queue_order TEXT NOT NULL)"
)
def _connect(self) -> sqlite3.Connection:
return connect_private_sqlite(self.path, timeout=self.timeout)
@staticmethod
def _login(login: str) -> str:
normalized = login.strip().lower()
if not normalized:
raise ValueError("login is required")
return normalized
@staticmethod
def _normalize(order: list[str] | tuple[str, ...]) -> list[str]:
if not isinstance(order, (list, tuple)) or len(order) != len(DEFAULT_QUEUE_ORDER):
raise ValueError("order must contain every routine queue")
if any(not isinstance(name, str) for name in order) or set(order) != set(DEFAULT_QUEUE_ORDER):
raise ValueError("order must contain every routine queue exactly once")
return list(order)
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
if row is None:
return {"revision": 0, "order": list(DEFAULT_QUEUE_ORDER)}, False
order, legacy = self._cipher.open(row[1], binding=f"order:{login}")
try:
normalized = self._normalize(order)
except ValueError as error:
raise PrivateStateEncryptionError("private state could not be decrypted") from error
return {"revision": int(row[0]), "order": normalized}, legacy
def get(self, login: str) -> dict:
login = self._login(login)
with self._connect() as connection:
row = connection.execute(
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
).fetchone()
snapshot, legacy = self._snapshot(row, login)
if row is not None and legacy:
connection.execute(
"UPDATE queue_priorities SET queue_order = ? WHERE login = ? AND queue_order = ?",
(self._cipher.seal(snapshot["order"], binding=f"order:{login}"), login, row[1]),
)
return snapshot
def replace(self, login: str, expected_revision: int, order: list[str]) -> dict:
login = self._login(login)
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
raise ValueError("revision is invalid")
normalized = self._normalize(order)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, queue_order FROM queue_priorities WHERE login = ?", (login,)
).fetchone()
current, _legacy = self._snapshot(row, login)
if current["revision"] != expected_revision:
raise QueuePriorityConflict(current)
revision = expected_revision + 1
sealed = self._cipher.seal(normalized, binding=f"order:{login}")
connection.execute(
"INSERT INTO queue_priorities(login, revision, queue_order) VALUES (?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, queue_order=excluded.queue_order",
(login, revision, sealed),
)
return {"revision": revision, "order": normalized}

178
src/recent_work_store.py Normal file
View File

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

View File

@ -8,7 +8,7 @@ API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
def request_body_limit(method: str, path: str) -> int | None:

View File

@ -76,6 +76,7 @@ class SecurityEventStore:
CREATE TABLE IF NOT EXISTS security_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT,
principal_id INTEGER,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
@ -93,6 +94,10 @@ class SecurityEventStore:
connection.execute(
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
)
if "principal_id" not in columns:
connection.execute(
"ALTER TABLE security_events ADD COLUMN principal_id INTEGER"
)
plaintext_columns = {"kind", "method", "device_label", "target"}
if "payload" not in columns or plaintext_columns.intersection(columns):
self._migrate_plaintext(
@ -102,6 +107,10 @@ class SecurityEventStore:
"CREATE INDEX IF NOT EXISTS security_events_created "
"ON security_events(created_at DESC, id DESC)"
)
connection.execute(
"CREATE INDEX IF NOT EXISTS security_events_principal "
"ON security_events(principal_id, id DESC)"
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
@ -126,6 +135,7 @@ class SecurityEventStore:
CREATE TABLE security_events_encrypted (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT,
principal_id INTEGER,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'completed',
operation_id TEXT
@ -145,7 +155,8 @@ class SecurityEventStore:
) in rows:
connection.execute(
"INSERT INTO security_events_encrypted "
"(id, payload, created_at, status, operation_id) VALUES (?, ?, ?, ?, ?)",
"(id, payload, principal_id, created_at, status, operation_id) "
"VALUES (?, ?, NULL, ?, ?, ?)",
(
event_id,
payload
@ -195,6 +206,7 @@ class SecurityEventStore:
self,
kind: str,
*,
principal_id: int,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
@ -203,8 +215,9 @@ class SecurityEventStore:
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(created_at, status) VALUES (?, 'completed')",
(now,),
"INSERT INTO security_events(principal_id, created_at, status) "
"VALUES (?, ?, 'completed')",
(principal_id, now),
)
event_id = cursor.lastrowid
payload = self._seal_event(
@ -224,6 +237,7 @@ class SecurityEventStore:
self,
kind: str,
*,
principal_id: int,
method: str | None = None,
device_label: str | None = None,
target: str | None = None,
@ -233,9 +247,9 @@ class SecurityEventStore:
try:
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO security_events(created_at, status, operation_id) "
"VALUES (?, 'pending', ?)",
(now, operation_id),
"INSERT INTO security_events(principal_id, created_at, status, operation_id) "
"VALUES (?, ?, 'pending', ?)",
(principal_id, now, operation_id),
)
event_id = cursor.lastrowid
connection.execute(
@ -282,12 +296,14 @@ class SecurityEventStore:
"Security activity is temporarily unavailable"
) from exc
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
def list(
self, *, principal_id: int, limit: int = 50, cursor: int | None = None
) -> SecurityEventPage:
bounded_limit = min(100, max(1, limit))
parameters: list[int] = []
where = ""
parameters: list[int] = [principal_id]
where = "WHERE principal_id = ?"
if cursor is not None:
where = "WHERE id < ?"
where += " AND id < ?"
parameters.append(cursor)
parameters.append(bounded_limit + 1)
try:

View File

@ -18,10 +18,20 @@ class SessionStatus(str):
"""String-compatible status carrying the server-confirmed idle deadline."""
idle_expires_at: int | None
principal_id: int | None
principal_login: str | None
def __new__(cls, value: str, idle_expires_at: int | None = None):
def __new__(
cls,
value: str,
idle_expires_at: int | None = None,
principal_id: int | None = None,
principal_login: str | None = None,
):
instance = super().__new__(cls, value)
instance.idle_expires_at = idle_expires_at
instance.principal_id = principal_id
instance.principal_login = principal_login
return instance
@ -71,7 +81,9 @@ class SessionStore:
management_id TEXT,
device_label TEXT,
created_at INTEGER,
last_active_at INTEGER
last_active_at INTEGER,
principal_id INTEGER,
principal_login TEXT
)
"""
)
@ -98,6 +110,8 @@ class SessionStore:
"device_label": "TEXT",
"created_at": "INTEGER",
"last_active_at": "INTEGER",
"principal_id": "INTEGER",
"principal_login": "TEXT",
}
for name, column_type in additions.items():
if name not in columns:
@ -135,6 +149,8 @@ class SessionStore:
*,
device_label: str = "This device",
management_id: str | None = None,
principal_id: int | None = None,
principal_login: str | None = None,
) -> None:
label = " ".join(str(device_label).split())[:64] or "This device"
now = int(self.clock())
@ -145,8 +161,9 @@ class SessionStore:
)
connection.execute(
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
") VALUES (?, ?, ?, ?, ?, ?)",
"session_hash, expires_at, management_id, device_label, created_at, last_active_at, "
"principal_id, principal_login"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
@ -154,6 +171,8 @@ class SessionStore:
label,
now,
now,
principal_id,
principal_login,
),
)
except (OSError, sqlite3.Error) as exc:
@ -176,7 +195,8 @@ class SessionStore:
) -> str:
now = int(self.clock())
query = (
"SELECT expires_at, last_active_at FROM active_sessions "
"SELECT expires_at, last_active_at, principal_id, principal_login "
"FROM active_sessions "
"WHERE session_hash = ?"
)
parameters = (self._digest(session_id),)
@ -185,7 +205,11 @@ class SessionStore:
with self._connect() as connection:
row = connection.execute(query, parameters).fetchone()
except sqlite3.OperationalError as exc:
if "no such column: last_active_at" not in str(exc):
missing_migrated_column = any(
f"no such column: {name}" in str(exc)
for name in ("last_active_at", "principal_id", "principal_login")
)
if not missing_migrated_column:
raise
with self._connect(initialize=True) as connection:
row = connection.execute(query, parameters).fetchone()
@ -196,7 +220,7 @@ class SessionStore:
idle_expires_at = min(row[0], row[1] + max(1, idle_timeout_seconds))
if idle_expires_at <= now:
return SessionStatus("idle", idle_expires_at)
return SessionStatus("active", idle_expires_at)
return SessionStatus("active", idle_expires_at, row[2], row[3])
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
@ -235,16 +259,18 @@ class SessionStore:
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
def list_active(
self, current_session_id: str, *, principal_id: int
) -> 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 > ? "
"FROM active_sessions WHERE expires_at > ? AND principal_id = ? "
"ORDER BY expires_at DESC, created_at DESC",
(now,),
(now, principal_id),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
@ -279,7 +305,11 @@ class SessionStore:
)[management_id]
def managed_statuses(
self, management_ids, *, idle_timeout_seconds: int
self,
management_ids,
*,
idle_timeout_seconds: int,
expected_principal_id: int | None = None,
) -> dict[str, str]:
"""Return authorization states for durable device identifiers in one read."""
requested = set(management_ids)
@ -290,7 +320,7 @@ class SessionStore:
with self._connect() as connection:
placeholders = ",".join("?" for _ in requested)
rows = connection.execute(
"SELECT management_id, expires_at, last_active_at "
"SELECT management_id, expires_at, last_active_at, principal_id "
f"FROM active_sessions WHERE management_id IN ({placeholders})",
tuple(requested),
).fetchall()
@ -305,6 +335,8 @@ class SessionStore:
statuses[management_id] = "revoked"
elif row[1] + idle_timeout <= now:
statuses[management_id] = "idle"
elif expected_principal_id is not None and row[2] != expected_principal_id:
statuses[management_id] = "principal_mismatch"
else:
statuses[management_id] = "active"
return statuses

View File

@ -2,6 +2,8 @@
import os
import pytest
os.environ.setdefault(
"STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY",
@ -10,4 +12,15 @@ os.environ.setdefault(
os.environ.setdefault(
"STACKCHAIN_PUSH_STATE_ENCRYPTION_KEY",
"cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHA=",
)
)
@pytest.fixture(autouse=True)
def stable_upstream_identity(monkeypatch):
"""Keep API tests off the network when security ownership resolves identity."""
from src import main
async def current_user():
return {"id": 42, "login": "timmy"}
monkeypatch.setattr(main, "current_user", current_user)

View File

@ -0,0 +1,231 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged adaptive Queues journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 375, "height": 667},
{"width": 430, "height": 932},
])
def test_adaptive_queues_put_truthful_next_action_above_the_fold(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.evaluate("""() => {
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.adaptiveQueueLauncher = createMobileQueueLauncher({
getCounts:() => ({delivery:1, gate:2, today:3, attentionUnavailable:true}),
rows,
nextAction:document.querySelector('#mobile-queue-next-action'),
activeList:document.querySelector('#mobile-queue-active-list'),
planningList:document.querySelector('#mobile-queue-planning-list'),
allList:document.querySelector('#mobile-queue-all-list'),
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
});
window.adaptiveQueueLauncher.renderPresentation();
document.querySelector('#mobile-queue-sheet').showModal();
}""")
next_action = page.locator("#mobile-queue-next-action")
expect(next_action).to_be_visible()
expect(next_action).to_have_text("Recover Delivery (1)")
bounds = next_action.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
assert bounds["y"] + bounds["height"] <= viewport["height"]
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["delivery", "gate", "attention"]
expect(page.locator('[data-mobile-queue="attention"]')).to_have_attribute("data-unavailable", "true")
assert page.locator("#mobile-queue-planning-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["today", "tomorrow", "week"]
expect(page.locator(".mobile-queue-all")).not_to_have_attribute("open", "")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 375, "height": 667},
{"width": 430, "height": 932},
])
@pytest.mark.parametrize(("queue", "count", "label", "destination"), [
("following", 3, "Review Following (3)", ["following"]),
("authored", 2, "Open My PRs (2)", ["filter:authored", "open:authored"]),
])
def test_adaptive_queues_open_existing_following_and_authored_work(viewport, queue, count, label, destination):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.evaluate("""({queue, count}) => {
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.destinations = [];
window.adaptiveQueueLauncher = createMobileQueueLauncher({
getCounts:() => ({[queue]:count}),
rows,
nextAction:document.querySelector('#mobile-queue-next-action'),
activeList:document.querySelector('#mobile-queue-active-list'),
planningList:document.querySelector('#mobile-queue-planning-list'),
allList:document.querySelector('#mobile-queue-all-list'),
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
openFollowing:() => window.destinations.push('following'),
selectFilter:name => window.destinations.push('filter:' + name),
firstAction:name => name === 'authored' ? {click:() => window.destinations.push('open:authored')} : null,
announce:message => window.destinations.push('announce:' + message),
});
window.adaptiveQueueLauncher.renderPresentation();
document.querySelector('#mobile-queue-next-action').addEventListener(
'click', () => window.adaptiveQueueLauncher.continueWork()
);
document.querySelector('#mobile-queue-sheet').showModal();
}""", {"queue": queue, "count": count})
next_action = page.locator("#mobile-queue-next-action")
expect(next_action).to_be_visible()
expect(next_action).to_have_text(label)
expect(next_action).to_have_attribute("aria-label", "Next up: " + label)
bounds = next_action.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["y"] + bounds["height"] <= viewport["height"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == [queue]
next_action.focus()
next_action.press("Enter")
assert page.evaluate("window.destinations") == destination
browser.close()
def test_adaptive_queues_keep_start_continue_actionable_offline_on_phone():
viewport = {"width": 390, "height": 844}
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.evaluate("""() => {
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.online = false;
window.destinations = [];
window.adaptiveQueueLauncher = createMobileQueueLauncher({
getCounts:() => ({delivery:1, gate:2, today:3}),
isOnline:() => window.online,
rows,
nextAction:document.querySelector('#mobile-queue-next-action'),
activeList:document.querySelector('#mobile-queue-active-list'),
planningList:document.querySelector('#mobile-queue-planning-list'),
allList:document.querySelector('#mobile-queue-all-list'),
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
openToday:() => window.destinations.push('today'),
});
window.adaptiveQueueLauncher.renderPresentation();
document.querySelector('#mobile-queue-next-action').addEventListener(
'click', () => window.adaptiveQueueLauncher.continueWork()
);
document.querySelector('#mobile-queue-sheet').showModal();
}""")
next_action = page.locator("#mobile-queue-next-action")
expect(next_action).to_be_visible()
expect(next_action).to_have_text("Continue Today (3)")
next_action.press("Enter")
assert page.evaluate("window.destinations") == ["today"]
page.evaluate("""() => {
window.online = true;
window.adaptiveQueueLauncher.renderPresentation();
}""")
expect(next_action).to_have_text("Recover Delivery (1)")
bounds = next_action.bounding_box()
assert bounds and bounds["height"] >= 44
assert bounds["y"] + bounds["height"] <= viewport["height"]
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 375, "height": 667},
{"width": 430, "height": 932},
])
def test_operator_reorders_routine_queues_without_leaking_priority_between_accounts(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-queue-priority.js")
page.add_script_tag(path=FRONTEND / "mobile-queue-launcher.js")
page.evaluate("""() => {
const values = new Map();
window.login = 'alice';
const rows = Object.fromEntries(Array.from(document.querySelectorAll('[data-mobile-queue]'))
.map(row => [row.dataset.mobileQueue, row]));
window.launcher = createMobileQueueLauncher({
getCounts:() => ({attention:2, following:3, authored:1}),
getRoutineOrder:() => window.priority.getOrder(), rows,
nextAction:document.querySelector('#mobile-queue-next-action'),
activeList:document.querySelector('#mobile-queue-active-list'),
planningList:document.querySelector('#mobile-queue-planning-list'),
allList:document.querySelector('#mobile-queue-all-list'),
activeSection:document.querySelector('#mobile-queue-active-list').parentElement,
});
window.priority = createMobileQueuePriority({
storage:{getItem:key => values.get(key) || null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:() => window.login, document,
list:document.querySelector('#mobile-queue-priority-list'),
resetButton:document.querySelector('#reset-mobile-queue-priority'),
status:document.querySelector('#mobile-queue-priority-status'),
labels:{attention:'Attention',today:'Today',update:'Updates',agenda:'Agenda',following:'Following',authored:'My PRs',filed:'Filed',later:'Later',draft:'Drafts'},
onChange:() => window.launcher.renderPresentation(),
});
window.priority.start(); window.launcher.renderPresentation();
document.querySelector('#mobile-queue-priority').open = true;
document.querySelector('#mobile-queue-sheet').showModal();
}""")
for _ in range(4):
page.get_by_role("button", name="Move Following earlier").click()
expect(page.locator("#mobile-queue-next-action")).to_have_text("Review Following (3)")
assert page.locator("#mobile-queue-active-list [data-mobile-queue]").evaluate_all(
"rows => rows.map(row => row.dataset.mobileQueue)"
) == ["following", "attention", "authored"]
expect(page.locator("#mobile-queue-priority-status")).to_have_text("Sync pending.")
controls = page.locator(".mobile-queue-priority-controls button")
assert controls.count() == 18
assert all((controls.nth(i).bounding_box() or {}).get("height", 0) >= 44 for i in range(controls.count()))
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("""() => {
window.login = 'bob'; window.priority.render(); window.launcher.renderPresentation();
}""")
expect(page.locator("#mobile-queue-next-action")).to_have_text("Start Attention (2)")
assert page.locator("#mobile-queue-priority-list [data-queue-priority]").evaluate_all(
"rows => rows.map(row => row.dataset.queuePriority)"
)[:3] == ["attention", "today", "update"]
browser.close()

View File

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

View File

@ -0,0 +1,85 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_commits_one_review_feedback_fix(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [{"id": 99, "path": "src/api.py", "body": "Handle empty state.", "line": 12}],
}],
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -12 +12 @@", "+return empty"]}],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.fixCalls = [];
globalThis.fixItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.fixDetail = detail;
globalThis.fixController = createPullSheet({fetchJson: async (path, options = {}) => {
fixCalls.push({path, options});
if ((options.method || 'GET') === 'GET') return {
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123', content:'return empty\\n'
};
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, fixController,
() => fixItem, () => fixDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
page.locator("#make-pull-feedback-fix").click()
page.locator("#pull-feedback-file-content").fill("return handled\n")
page.locator("#pull-feedback-commit-message").fill("fix: handle empty state")
page.locator("#commit-pull-feedback-fix").click()
page.wait_for_function("fixCalls.length === 2")
result = page.evaluate("""() => ({
calls:fixCalls, head:fixDetail.head_sha,
editorHidden:document.querySelector('#pull-feedback-file-editor').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["editorHidden"] is True
assert "committed" in result["status"].lower()
assert "path=src%2Fapi.py" in result["calls"][0]["path"]
body = result["calls"][1]["options"]["body"]
assert '"expected_blob_sha":"blob123"' in body
assert '"content":"return handled\\n"' in body

View File

@ -0,0 +1,113 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_stages_and_commits_cross_file_suggestions_atomically(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [
{"id": 99, "path": "src/api.py", "line": 2, "body": "Use the typed empty result.",
"suggestion": {"line": 2, "content": " return empty_result()"}},
{"id": 100, "path": "tests/test_api.py", "line": 2, "body": "Prove the typed result.",
"suggestion": {"line": 2, "content": " assert lookup() == empty_result()"}},
],
}],
"files": [
{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ return None"]},
{"filename": "tests/test_api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ assert lookup() is None"]},
],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.batchCalls = [];
globalThis.batchItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.batchDetail = detail;
globalThis.batchController = createPullSheet({fetchJson: async (path, options = {}) => {
batchCalls.push({path, options});
if ((options.method || 'GET') === 'GET') {
if (batchCalls.length === 1) return {path:'src/api.py', head_sha:'abc1234', blob_sha:'blob1',
content:'def lookup():\\n return None\\n'};
return {path:'tests/test_api.py', head_sha:'abc1234', blob_sha:'blob2',
content:'def test_lookup():\\n assert lookup() is None\\n'};
}
return {paths:['src/api.py','tests/test_api.py'], suggestion_count:2,
previous_head_sha:'abc1234', head_sha:'def5678'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, batchController,
() => batchItem, () => batchDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("batchCalls.length === 1")
page.locator("#stage-pull-feedback-suggestion").click()
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (1)"
page.locator("#next-pull-feedback").click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("batchCalls.length === 2")
page.locator("#stage-pull-feedback-suggestion").click()
assert page.locator("#review-pull-feedback-batch").text_content() == "Review fixes (2)"
page.locator("#review-pull-feedback-batch").click()
assert page.locator("#pull-feedback-batch-review").is_visible()
assert page.locator("#pull-feedback-batch-items li").count() == 2
page.locator("#commit-pull-feedback-batch").click()
page.wait_for_function("batchCalls.length === 3")
result = page.evaluate("""() => ({
calls:batchCalls, head:batchDetail.head_sha,
reviewHidden:document.querySelector('#pull-feedback-batch-review').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["reviewHidden"] is True
assert "2 suggested changes committed" in result["status"].lower()
request = result["calls"][2]
assert request["path"].endswith("/feedback-batch")
assert request["options"]["method"] == "POST"
body = request["options"]["body"]
assert '"expected_head_sha":"abc1234"' in body
assert '"files":[{"path":"src/api.py"' in body
assert '"path":"tests/test_api.py"' in body
assert '"suggestion_count":2' in body
assert 'return empty_result()' in body
assert 'assert lookup() == empty_result()' in body

View File

@ -0,0 +1,103 @@
from pathlib import Path
import pytest
ROOT = Path(__file__).parents[2]
@pytest.mark.parametrize("viewport", [(320, 568), (390, 844)])
def test_mobile_author_previews_and_applies_review_suggestion(viewport):
playwright = pytest.importorskip("playwright.sync_api")
html = (ROOT / "frontend" / "index.html").read_text()
detail = {
"state": "open", "merged": False, "head_sha": "abc1234",
"capabilities": {"authored": True},
"reviewers": [{
"review_id": 42, "login": "sam", "status": "changes_requested",
"head_sha": "abc1234", "blocking": True, "feedback_state": "loaded",
"comments": [{
"id": 99, "path": "src/api.py", "line": 2,
"body": "Return the typed empty result.\n```suggestion\n return empty_result()\n```",
"suggestion": {"line": 2, "content": " return empty_result()"},
}, {
"id": 100, "path": "src/api.py", "line": 3,
"body": "Keep the result typed.",
}],
}],
"files": [{"filename": "src/api.py", "status": "modified", "diff_available": True,
"diff_lines": ["@@ -2 +2 @@", "+ return None"]}],
}
with playwright.sync_playwright() as runtime:
try:
browser = runtime.chromium.launch(headless=True)
except Exception as error:
pytest.skip(f"Chromium is not installed: {error}")
page = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
page.set_content(html, wait_until="domcontentloaded")
page.add_style_tag(path=ROOT / "frontend" / "dashboard.css")
page.add_script_tag(path=ROOT / "frontend" / "pull-sheet.js")
page.evaluate(
"""detail => {
globalThis.confirm = () => true;
globalThis.suggestionCalls = [];
globalThis.suggestionItem = {repository:'stackchain/api', number:7, key:'stackchain/api#7'};
globalThis.suggestionDetail = detail;
globalThis.suggestionController = createPullSheet({fetchJson: async (path, options = {}) => {
suggestionCalls.push({path, options});
if ((options.method || 'GET') === 'GET') return {
path:'src/api.py', head_sha:'abc1234', blob_sha:'blob123',
content:'def lookup():\\n return None\\n'
};
return {path:'src/api.py', previous_head_sha:'abc1234', head_sha:'def5678', blob_sha:'blob456'};
}});
document.querySelector('#pull-sheet').classList.add('open');
document.querySelector('#pull-review').open = true;
document.querySelector('#pull-files').innerHTML = detail.files.map((file, index) =>
createPullSheet.renderFile(file, index, false, value => String(value))
).join('');
createPullSheet.bindFeedbackControls(document, suggestionController,
() => suggestionItem, () => suggestionDetail, () => 'alex');
createPullSheet.review(detail, null, document);
}""",
detail,
)
page.locator(".pull-review-feedback summary").click()
page.locator('[data-address-review-feedback="42"]').click()
assert page.locator("#pull-feedback-file-editor").is_hidden()
assert page.locator("#review-pull-feedback-suggestion").is_visible()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 1")
assert page.locator("#pull-feedback-suggestion-preview").is_visible()
assert page.locator("#pull-feedback-suggestion-before").text_content() == " return None"
assert page.locator("#pull-feedback-suggestion-after").text_content() == " return empty_result()"
assert page.locator("#pull-feedback-file-editor").is_hidden()
page.locator("#next-pull-feedback").click()
assert page.locator("#pull-feedback-suggestion-preview").is_hidden()
assert page.locator("#review-pull-feedback-suggestion").is_hidden()
page.locator("#previous-pull-feedback").click()
page.locator("#review-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 2")
page.locator("#apply-pull-feedback-suggestion").click()
page.wait_for_function("suggestionCalls.length === 3")
result = page.evaluate("""() => ({
calls:suggestionCalls, head:suggestionDetail.head_sha,
previewHidden:document.querySelector('#pull-feedback-suggestion-preview').hidden,
status:document.querySelector('#pull-feedback-status').textContent,
scrollWidth:document.documentElement.scrollWidth,
clientWidth:document.documentElement.clientWidth,
heights:Array.from(document.querySelectorAll('#pull-feedback-pass button'))
.filter(button => button.getClientRects().length > 0).map(button => button.getBoundingClientRect().height),
})""")
browser.close()
assert result["scrollWidth"] <= result["clientWidth"]
assert min(result["heights"]) >= 44
assert result["head"] == "def5678"
assert result["previewHidden"] is True
assert "suggested change committed" in result["status"].lower()
body = result["calls"][2]["options"]["body"]
assert '"expected_blob_sha":"blob123"' in body
assert '"content":"def lookup():\\n return empty_result()\\n"' in body

View File

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

View File

@ -0,0 +1,115 @@
import os
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip(
"packaged mobile comment-reaction journey runs only in its gated CI job",
allow_module_level=True,
)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
def test_comment_reactions_are_touch_safe_and_focus_preserving_at_320px():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content(
'<main><div id="status" aria-live="polite"></div>'
'<div id="comments"><article class="issue-comment" data-comment-id="91">'
'<p>Ship feedback</p><div id="actions"></div></article></div></main>'
)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "comment-actions.js")
page.evaluate(
"""
window.reactionCalls = [];
window.offline = false;
const controller = createCommentActions({
getLogin: () => 'timmy',
fetchJson: async (path, options = {}) => {
reactionCalls.push([path, options.method || 'GET']);
return {
comment_id: 91,
reactions: [{content: 'heart', count: options.method === 'PUT' ? 2 : 1,
selected: options.method === 'PUT'}],
};
},
});
document.querySelector('#actions').innerHTML = controller.actionHtml({id: 91, author: 'alex'});
controller.wire({
root: document.querySelector('#comments'),
getSurface: () => ({
context: {kind: 'issue', item: {repository: 'stackchain/api', number: 7}},
status: document.querySelector('#status'),
}),
isOffline: () => window.offline,
escapeHtml: value => String(value),
});
"""
)
trigger = page.get_by_role("button", name="React to this comment")
trigger.click()
menu = page.locator("[data-comment-reaction-menu]")
expect(menu).to_be_visible()
for control in menu.get_by_role("button").all():
box = control.bounding_box()
assert box is not None and box["height"] >= 44
page.get_by_role("button", name="Heart 1").click()
expect(menu).to_be_hidden()
expect(trigger).to_be_focused()
expect(page.locator("#status")).to_have_text("Reaction updated.")
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
assert page.evaluate("window.reactionCalls") == [
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions", "GET"],
["api/v1/repos/stackchain/api/issues/7/comments/91/reactions/heart", "PUT"],
]
browser.close()
def test_search_following_conversation_actions_are_touch_safe_at_320px():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content(
'<main><section id="search-preview-conversation" class="search-preview-conversation">'
'<div id="search-preview-comments"></div>'
'<div id="search-preview-conversation-status"></div>'
'<button id="retry-search-preview-conversation" hidden></button>'
'<button id="load-older-search-preview-comments" hidden></button>'
'</section></main>'
)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js")
page.add_script_tag(path=FRONTEND / "comment-actions.js")
page.evaluate(
"""
const actions=createCommentActions({fetchJson:async()=>({}),getLogin:()=> 'timmy'});
renderSearchPreviewConversation({status:'ready',comments:[
{id:41,author:'timmy',body:'Correct this from Following'},
{id:42,author:'alexander',body:'React without leaving Search'},
]},document,value=>String(value),value=>String(value),value=>String(value),actions);
"""
)
expect(page.locator(".search-preview-comment.issue-comment")).to_have_count(2)
expect(page.get_by_role("button", name="Edit")).to_have_count(1)
expect(page.get_by_role("button", name="Delete")).to_have_count(1)
expect(page.get_by_role("button", name="React to this comment")).to_have_count(2)
for control in page.locator("[data-comment-action], [data-comment-reactions-open]").all():
box = control.bounding_box()
assert box is not None and box["height"] >= 44
assert page.evaluate(
"document.documentElement.scrollWidth > document.documentElement.clientWidth"
) is False
browser.close()

View File

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

View File

@ -0,0 +1,44 @@
from __future__ import annotations
import os
import re
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("rendered mobile quiet-hours checks run only in the browser gate", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "frontend"
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_notification_quiet_hours_are_phone_usable_without_horizontal_scroll(viewport):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.locator("#work-settings-toggle").click()
toggle = page.locator('label[for="push-quiet-hours"]')
start = page.locator("#push-quiet-start")
end = page.locator("#push-quiet-end")
expect(toggle).to_be_visible()
expect(start).to_be_visible()
expect(end).to_be_visible()
expect(start).to_have_value("22:00")
expect(end).to_have_value("07:00")
for control in (toggle, start, end):
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()

View File

@ -114,6 +114,13 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
process.communicate()
def hydrate_workspace(page) -> None:
page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
def indexed_issue_records(page: Page) -> list[dict]:
return page.evaluate(
"""async () => {
@ -275,6 +282,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert fake.created_issues == []
page.reload(wait_until="domcontentloaded")
hydrate_workspace(page)
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
durable_after_reload = indexed_issue_records(page)
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
@ -306,9 +314,13 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
f"requests={fake.requests[-20:]!r}"
)
# Deferred workspace startup can report its already-issued offline request
# after the pre-reconnect clear; retain every non-offline browser error.
# after the pre-reconnect clear. Chromium uses either its explicit offline
# code or a generic fetch rejection for that same request, depending on
# whether the service worker or page observes the disconnect first.
browser_errors[:] = [
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
error for error in browser_errors
if "ERR_INTERNET_DISCONNECTED" not in error
and not error.startswith("context failed TypeError: Failed to fetch")
]
for _ in range(40):
@ -318,6 +330,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
page.wait_for_timeout(100)
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.evaluate("window.dispatchEvent(new Event('online'))")
for _ in range(40):
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))

View File

@ -0,0 +1,100 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged mobile photo-draft journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must provide exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_thread.start()
fake_url = f"http://127.0.0.1:{fake.server_port}"
try:
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
page = context.new_page()
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Photo draft release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""() => new Promise((resolve, reject) => {
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
request.onerror=()=>reject(request.error);
request.onupgradeneeded=()=>{
if (!request.result.objectStoreNames.contains('drafts')) {
request.result.createObjectStore('drafts',{keyPath:'id'});
}
};
request.onsuccess=()=>{
const transaction=request.result.transaction('drafts','readwrite');
transaction.objectStore('drafts').put({
id:'timmy:issue:acme%2Fmobile%3A41', version:1, ownerLogin:'timmy',
scope:'conversation', kind:'issue', repository:'acme/mobile', number:41,
updatedAt:Date.now(), attachments:[{
filename:'rack-label.jpg', contentType:'image/jpeg',
blob:new Blob(['field evidence'],{type:'image/jpeg'}), note:'Rack label',
}],
});
transaction.oncomplete=resolve;
transaction.onerror=()=>reject(transaction.error);
};
})"""
)
page.reload(wait_until="networkidle")
hydrate_workspace(page)
stored = page.evaluate(
"""() => new Promise((resolve,reject)=>{
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
request.onerror=()=>reject(request.error);
request.onsuccess=()=>{
const values=request.result.transaction('drafts','readonly').objectStore('drafts').getAll();
values.onsuccess=()=>resolve(values.result.map(item=>({ownerLogin:item.ownerLogin,kind:item.kind,number:item.number})));
values.onerror=()=>reject(values.error);
};
})"""
)
assert stored == [{"ownerLogin": "timmy", "kind": "issue", "number": 41}]
inventory = page.evaluate(
"""() => createConversationReplyDraftStore({indexedDB,getOwnerLogin:()=> 'timmy'}).list()"""
)
assert len(inventory) == 1
expect(page.locator('[data-work-count="draft"]')).to_have_text("1", timeout=10_000)
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="draft"]').click()
card = page.locator(".draft-card", has_text="acme/mobile#41")
expect(card).to_contain_text("1 saved photo")
resume = card.get_by_role("button", name="Resume draft")
bounds = resume.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
resume.dispatch_event("click")
expect(page.locator("#issue-sheet")).to_be_visible()
expect(page.locator("#issue-attachment-preview")).to_be_visible()
expect(page.locator("#issue-attachment-meta")).to_contain_text("rack-label.jpg")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)

View File

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

View File

@ -89,3 +89,47 @@ def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport):
assert result["state"] == "Checks running"
assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"]
browser.close()
@pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_failed_release_prepares_a_visible_draft_rollback_receipt_on_phone(viewport):
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "release-receipt.js")
result = page.evaluate("""async () => {
const values=new Map(), calls=[], opened=[];
const receipt=createReleaseReceipt({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', listNode:document.querySelector('#release-watchlist'),
confirmAction:()=>true, openPull:pull=>opened.push(pull),
fetchJson:async(path, options={})=>{
calls.push({path,method:options.method||'GET'});
if(path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'failed'};
if(path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,rollback_of:'abc1234',url:'https://forge.example/git/stackchain/api/pulls/9'};
return {commit_sha:'abc1234',ci_state:'failure',release:null,checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}]};
},
});
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'},{merge_commit_sha:'abc1234'});
document.querySelector('#release-receipt-sheet').showModal();
await receipt.refresh();
document.querySelector('.release-failure-summary button').click();
await new Promise(resolve=>setTimeout(resolve,0));
const rollback=[...document.querySelectorAll('.release-failure-actions button')].find(button=>button.textContent==='Prepare rollback PR');
const actionHeight=rollback.getBoundingClientRect().height;
rollback.click();
await new Promise(resolve=>setTimeout(resolve,0));
const status=document.querySelector('.release-rollback-status');
return {calls,opened,actionHeight,status:status?.textContent||'',overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth};
}""")
assert result["actionHeight"] >= 44
assert result["status"] == "Draft rollback #9 is ready for review."
assert result["opened"][0]["number"] == 9
assert result["calls"][-1]["path"].endswith("/release-receipt/abc1234/rollback")
assert result["overflow"] is False
browser.close()

View File

@ -88,3 +88,62 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
page.set_viewport_size({"width": 800, "height": 700})
expect(navigation).to_be_hidden()
browser.close()
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_rendered_search_to_preview_journey_contains_focus_until_final_close(width, height):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-search-modal.js")
page.evaluate("""() => {
const palette = document.querySelector('#cmd-palette');
const preview = document.querySelector('#search-preview');
palette.classList.add('open');
window.searchModalTest = createMobileSearchModal({document});
window.searchModalTest.activate(palette, {
opener:document.querySelector('[data-mobile-task="search"]'),
initialFocus:document.querySelector('#cmd-input'),
});
preview.append(Object.assign(document.createElement('button'), {id:'dynamic-preview-action', textContent:'Dynamic action'}));
}""")
expect(page.locator("#cmd-input")).to_be_focused()
expect(page.locator("#cmd-palette")).to_have_attribute("aria-modal", "true")
assert page.locator("body > header").evaluate("node => node.inert") is True
assert page.locator("main").evaluate("node => node.inert") is True
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is True
page.evaluate("""() => {
document.querySelector('#cmd-palette').classList.remove('open');
document.querySelector('#search-preview').classList.add('open');
searchModalTest.transition(document.querySelector('#search-preview'), {
initialFocus:document.querySelector('#close-search-preview'),
});
document.querySelector('#dynamic-preview-action').focus();
}""")
page.keyboard.press("Tab")
expect(page.locator("#close-search-preview")).to_be_focused()
assert page.locator("main").evaluate("node => node.inert") is True
page.evaluate("""() => {
document.querySelector('#search-preview').classList.remove('open');
document.querySelector('#cmd-palette').classList.add('open');
searchModalTest.transition(document.querySelector('#cmd-palette'), {
initialFocus:document.querySelector('#cmd-input'),
});
}""")
expect(page.locator("#cmd-input")).to_be_focused()
assert page.locator("main").evaluate("node => node.inert") is True
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("searchModalTest.deactivate()")
expect(page.locator('[data-mobile-task="search"]')).to_be_focused()
assert page.locator("body > header").evaluate("node => node.inert") is False
assert page.locator("main").evaluate("node => node.inert") is False
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is False
browser.close()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,39 @@ from src.passkey_store import PasskeyStore
from src.session_store import SessionStore, SessionStoreError
def test_global_access_revocation_preserves_other_principal_credentials_and_sessions(
tmp_path,
):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
for principal_id in (42, 84):
sessions.activate(
f"session-{principal_id}",
2_000,
management_id=f"management-{principal_id}",
principal_id=principal_id,
principal_login=f"operator-{principal_id}",
)
passkeys.register(
credential_id=f"credential-{principal_id}".encode(),
public_key=f"public-key-{principal_id}".encode(),
sign_count=0,
device_label=f"Device {principal_id}",
management_id=f"management-{principal_id}",
principal_id=principal_id,
)
passkeys.revoke_all_access(principal_id=42)
assert passkeys.all(principal_id=42) == []
assert [item.principal_id for item in passkeys.all(principal_id=84)] == [84]
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
).fetchall() == [(84,)]
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
@ -14,6 +47,8 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
2_000,
management_id="phone-management-id",
device_label="Phone",
principal_id=42,
principal_login="timmy",
)
sessions.mint_step_up(
"phone-session",
@ -28,6 +63,7 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
principal_id=42,
)
passkeys.issue_challenge(
b"pending-challenge",
@ -43,7 +79,7 @@ def test_global_access_revocation_rolls_back_every_credential_and_session_change
)
with pytest.raises(SessionStoreError):
passkeys.revoke_all_access()
passkeys.revoke_all_access(principal_id=42)
with sqlite3.connect(database) as connection:
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
@ -63,6 +99,8 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
2_000,
management_id=f"{slug}-management-id",
device_label=label,
principal_id=42,
principal_login="timmy",
)
sessions.mint_step_up(
f"{slug}-session",
@ -76,6 +114,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
sign_count=0,
device_label=label,
management_id=f"{slug}-management-id",
principal_id=42,
)
with sqlite3.connect(database) as connection:
connection.execute(
@ -85,7 +124,7 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
)
with pytest.raises(SessionStoreError):
passkeys.revoke_device_access("phone-management-id")
passkeys.revoke_device_access("phone-management-id", principal_id=42)
with sqlite3.connect(database) as connection:
assert connection.execute(
@ -95,3 +134,69 @@ def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
def test_device_access_revocation_cannot_cross_principal_boundary(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
for principal_id in (42, 84):
sessions.activate(
f"session-{principal_id}",
2_000,
management_id=f"management-{principal_id}",
principal_id=principal_id,
principal_login=f"operator-{principal_id}",
)
sessions.mint_step_up(
f"session-{principal_id}",
action="close_issue",
target="stackchain/dashboard#1",
ttl_seconds=90,
)
passkeys.register(
credential_id=f"credential-{principal_id}".encode(),
public_key=f"public-key-{principal_id}".encode(),
sign_count=0,
device_label=f"Device {principal_id}",
management_id=f"management-{principal_id}",
principal_id=principal_id,
)
assert passkeys.revoke_device_access("management-84", principal_id=42) is False
assert passkeys.revoke_device_access("management-42", principal_id=42) is True
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT principal_id FROM active_sessions ORDER BY principal_id"
).fetchall() == [(84,)]
assert connection.execute(
"SELECT principal_id FROM passkey_credentials ORDER BY principal_id"
).fetchall() == [(84,)]
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
def test_global_access_revocation_returns_only_affected_management_ids(tmp_path):
database = tmp_path / "sessions.sqlite3"
sessions = SessionStore(database, clock=lambda: 1_000.0)
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
for principal_id in (42, 84):
for suffix in ("phone", "laptop"):
sessions.activate(
f"session-{principal_id}-{suffix}",
2_000,
management_id=f"management-{principal_id}-{suffix}",
principal_id=principal_id,
principal_login=f"operator-{principal_id}",
)
affected = passkeys.revoke_all_access(principal_id=42)
assert affected == ["management-42-laptop", "management-42-phone"]
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT management_id FROM active_sessions ORDER BY management_id"
).fetchall() == [
("management-84-laptop",),
("management-84-phone",),
]

View File

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

View File

@ -1,13 +1,44 @@
from pathlib import Path
import re
WORKFLOW = Path(".gitea/workflows/ci.yml")
ACTION_REFERENCE = re.compile(
r"^\s*- uses: (?P<action>[^\s@]+)@(?P<revision>[^\s#]+)\s+# (?P<version>v\d+(?:\.\d+){1,2})$",
re.MULTILINE,
)
DOWNLOAD_ARTIFACT = (
"actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2"
)
def test_ci_uses_gitea_compatible_artifact_action():
text = WORKFLOW.read_text()
assert "actions/upload-artifact@v4" not in text
assert "actions/upload-artifact@v3" in text
assert re.search(
r"actions/upload-artifact@[0-9a-f]{40}\s+# v3\.\d+\.\d+", text
)
def test_ci_pins_every_external_action_to_a_reviewable_commit():
text = WORKFLOW.read_text()
uses_lines = [line for line in text.splitlines() if line.lstrip().startswith("- uses:")]
references = list(ACTION_REFERENCE.finditer(text))
assert len(references) == len(uses_lines)
assert references
for reference in references:
assert re.fullmatch(r"[0-9a-f]{40}", reference["revision"])
def test_ci_grants_repository_write_access_only_to_release_job():
text = WORKFLOW.read_text()
jobs = text[text.index("jobs:") :]
before_release, release = jobs.split(" release-candidate:", 1)
assert "permissions:\n contents: read" in text[: text.index("jobs:")]
assert "contents: write" not in before_release
assert "permissions:\n contents: write" in release
def test_ci_installs_declared_requirements_before_tests():
@ -40,7 +71,7 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert "needs: [lint, build-release, browser-journey]" in release
assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release
assert DOWNLOAD_ARTIFACT in release
assert (
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
in release
@ -48,76 +79,16 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
def test_release_promotion_waits_for_packaged_mobile_journeys():
def test_release_promotion_waits_for_every_packaged_mobile_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
release = text[text.index(" release-candidate:") :]
assert "needs: build-release" in browser
assert "actions/download-artifact@v3" in browser
assert DOWNLOAD_ARTIFACT in browser
assert "pip install -r requirements-e2e.txt" in browser
assert "python3 -m playwright install --with-deps chromium" in browser
assert 'STACKCHAIN_RUN_RELEASE_E2E: "1"' in browser
assert (
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
"tests/e2e/test_mobile_search_preview_navigation.py "
"tests/e2e/test_mobile_search_week_plan.py "
"tests/e2e/test_mobile_find_work_release.py "
"tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_sign_out_release.py "
"tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_tomorrow_conflict_release.py "
"tests/e2e/test_mobile_week_ahead_release.py "
"tests/e2e/test_mobile_today_week_reschedule_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py "
"tests/e2e/test_mobile_following_release.py "
"tests/e2e/test_mobile_detail_watch_release.py "
"tests/e2e/test_mobile_pull_reviewer_status_release.py "
"tests/e2e/test_mobile_pull_reviewer_feedback_release.py -q"
) in browser
assert "python3 -m pytest tests/e2e -q" in browser
assert "test_mobile_" not in browser
assert "needs: [lint, build-release, browser-journey]" in release
def test_browser_job_runs_packaged_today_week_reschedule_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_today_week_reschedule_release.py" in browser
def test_browser_job_gates_latest_pull_review_mobile_journeys():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_address_review_feedback_release.py" in browser
assert "tests/e2e/test_mobile_cancel_pull_review_request_release.py" in browser
def test_browser_job_gates_authored_pull_queue_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_authored_pull_queue_release.py" in browser
def test_browser_job_gates_mobile_source_branch_cleanup_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_source_branch_cleanup_release.py" in browser
def test_browser_job_gates_authored_pull_close_recovery_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_close_authored_pull_release.py" in browser
def test_browser_job_gates_search_authored_pull_recovery_journey():
text = WORKFLOW.read_text()
browser = text[text.index(" browser-journey:") : text.index(" release-candidate:")]
assert "tests/e2e/test_mobile_search_authored_pull_recovery_release.py" in browser

View File

@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v150" in worker

View File

@ -0,0 +1,308 @@
import json
from pathlib import Path
import httpx
import pytest
from src import gitea_proxy, main
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.anyio
async def test_comment_reactions_are_bound_to_conversation_and_aggregate_operator_state(monkeypatch):
calls = []
async def fake_fetch(path):
calls.append(("fetch", path))
if path == "user":
return {"login": "timmy"}
if path == "repos/stackchain/api/issues/comments/91":
return {
"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7",
}
raise AssertionError(path)
class Response:
def raise_for_status(self):
return None
def json(self):
return [
{"content": "heart", "user": {"login": "alex"}},
{"content": "heart", "user": {"login": "timmy"}},
{"content": "+1", "user": {"login": "alex"}},
{"content": "unsupported", "user": {"login": "alex"}},
{"content": "heart", "user": {}},
]
class Client:
async def get(self, path, headers):
calls.append(("get", path))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
assert result == {
"comment_id": 91,
"reactions": [
{"content": "+1", "count": 1, "selected": False},
{"content": "heart", "count": 2, "selected": True},
],
}
assert calls == [
("fetch", "user"),
("fetch", "repos/stackchain/api/issues/comments/91"),
("get", "/api/v1/repos/stackchain/api/issues/comments/91/reactions"),
]
@pytest.mark.anyio
async def test_comment_binding_accepts_gitea_ui_issue_url_shape(monkeypatch):
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {
"issue_url": "https://forge.example/git/stackchain/api/issues/7",
}
class Response:
def raise_for_status(self):
return None
def json(self):
return []
class Client:
async def get(self, path, headers):
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
result = await gitea_proxy.comment_reactions("stackchain/api", 7, 91)
assert result == {"comment_id": 91, "reactions": []}
@pytest.mark.anyio
async def test_comment_reaction_mutation_is_idempotent_and_returns_confirmed_state(monkeypatch):
payloads = [[], [{"content": "heart", "user": {"login": "timmy"}}]]
mutations = []
async def fake_fetch(path):
if path == "user":
return {"login": "timmy"}
return {"issue_url": "https://forge.example/api/v1/repos/stackchain/api/issues/7"}
class Response:
def __init__(self, payload=None):
self.payload = payload
def raise_for_status(self):
return None
def json(self):
return self.payload
class Client:
async def get(self, path, headers):
return Response(payloads.pop(0))
async def post(self, path, headers, json):
mutations.append(("post", path, json))
return Response()
async def delete(self, path, headers, json):
mutations.append(("delete", path, json))
return Response()
monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example")
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
result = await gitea_proxy.set_comment_reaction("stackchain/api", 7, 91, "heart", True)
path = "/api/v1/repos/stackchain/api/issues/comments/91/reactions"
assert mutations == [("post", path, {"content": "heart"})]
assert result == {
"comment_id": 91,
"reactions": [{"content": "heart", "count": 1, "selected": True}],
}
@pytest.mark.anyio
async def test_reaction_api_authorizes_issue_pull_and_notification_contexts(monkeypatch):
reads = []
writes = []
async def assigned(repository, number):
return (repository, number) == ("stackchain/api", 7)
async def pull_capabilities(repository, number):
return {"can_open": (repository, number) == ("stackchain/api", 8)}
async def notification_target(thread_id):
assert thread_id == 42
return "stackchain/api", 9
async def reactions(repository, number, comment_id):
reads.append((repository, number, comment_id))
return {"comment_id": comment_id, "reactions": []}
async def set_reaction(repository, number, comment_id, content, active):
writes.append((repository, number, comment_id, content, active))
return {
"comment_id": comment_id,
"reactions": [{"content": content, "count": 1, "selected": active}],
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main, "_pull_workspace_capabilities", pull_capabilities)
monkeypatch.setattr(main, "_has_pull_workspace_access", lambda value: value.get("can_open") is True)
monkeypatch.setattr(main.gitea_proxy, "notification_conversation_target", notification_target)
monkeypatch.setattr(main.gitea_proxy, "comment_reactions", reactions, raising=False)
monkeypatch.setattr(main.gitea_proxy, "set_comment_reaction", set_reaction, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
issue = await client.get("/api/v1/repos/stackchain/api/issues/7/comments/91/reactions")
pull = await client.get("/api/v1/repos/stackchain/api/pulls/8/comments/92/reactions")
update = await client.get("/api/v1/notifications/42/comments/93/reactions")
changed = await client.put(
"/api/v1/notifications/42/comments/93/reactions/heart",
json={"active": True},
)
assert [issue.status_code, pull.status_code, update.status_code, changed.status_code] == [200, 200, 200, 200]
assert reads == [
("stackchain/api", 7, 91),
("stackchain/api", 8, 92),
("stackchain/api", 9, 93),
]
assert writes == [("stackchain/api", 9, 93, "heart", True)]
assert changed.json()["reactions"][0]["selected"] is True
def run_node(script: str) -> dict:
import subprocess
result = subprocess.run(
["node", "-e", script], cwd=ROOT, text=True, capture_output=True, check=True
)
return json.loads(result.stdout)
def test_comment_action_controller_loads_and_toggles_authoritative_reactions():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const calls=[];
const actions=createActions({{
fetchJson:async (path, options={{}})=>{{
calls.push([path, options.method || 'GET', options.body || null]);
return options.method === 'PUT'
? {{comment_id:91,reactions:[{{content:'heart',count:2,selected:true}}]}}
: {{comment_id:91,reactions:[{{content:'heart',count:1,selected:false}}]}};
}},
getLogin:()=> 'timmy',
}});
(async()=>{{
const context={{kind:'issue',item:{{repository:'stackchain/api',number:7}}}};
const loaded=await actions.loadReactions(context,91);
const changed=await actions.setReaction(context,91,'heart',true);
process.stdout.write(JSON.stringify({{loaded,changed,calls,html:actions.actionHtml({{id:91,author:'alex'}})}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
base = "api/v1/repos/stackchain/api/issues/7/comments/91/reactions"
assert output["calls"] == [
[base, "GET", None],
[base + "/heart", "PUT", '{"active":true}'],
]
assert output["loaded"]["reactions"][0]["count"] == 1
assert output["changed"]["reactions"][0]["selected"] is True
assert 'data-comment-reactions-open' in output["html"]
assert 'aria-label="React to this comment"' in output["html"]
def test_comment_reaction_menu_renders_mobile_safe_counts_and_selection():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const actions=createActions({{fetchJson:async()=>{{}},getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{html:actions.reactionHtml({{
comment_id:91,
reactions:[{{content:'heart',count:2,selected:true}},{{content:'+1',count:1,selected:false}}],
}})}}));
"""
html = run_node(script)["html"]
assert 'data-comment-reaction="heart"' in html
assert 'aria-pressed="true"' in html
assert "Heart 2" in html
assert 'data-comment-reaction="rocket"' in html
assert "Rocket 0" in html
assert 'data-comment-reactions-close' in html
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".comment-reactions" in css
assert "flex-wrap:wrap" in css
assert ".comment-reaction-menu button" in css
assert "min-height:44px" in css
def test_comment_reaction_wire_loads_menu_and_returns_focus_after_toggle():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
const calls=[];
const menu={{hidden:true,innerHTML:''}};
const trigger={{disabled:false,expanded:'false',focused:0,setAttribute:(n,v)=>trigger.expanded=v,focus:()=>trigger.focused++,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
const reaction={{disabled:false,dataset:{{commentReaction:'heart'}},getAttribute:()=> 'false',closest:s=>s==='[data-comment-reaction]'?reaction:(s==='.issue-comment'?card:null)}};
const card={{dataset:{{commentId:'91'}},querySelector:s=>s==='[data-comment-reaction-menu]'?menu:trigger}};
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
const status={{textContent:''}};
const surface={{context:{{kind:'update',item:{{notification_id:42}}}},status}};
const actions=createActions({{
fetchJson:async (path,options={{}})=>{{calls.push([path,options.method||'GET']);return {{comment_id:91,reactions:[{{content:'heart',count:1,selected:options.method==='PUT'}}]}};}},
getLogin:()=> 'timmy',
}});
actions.wire({{root,getSurface:()=>surface,isOffline:()=>false,escapeHtml:String}});
(async()=>{{
await root.listeners.click({{target:trigger}});
await root.listeners.click({{target:reaction}});
process.stdout.write(JSON.stringify({{calls,hidden:menu.hidden,html:menu.innerHTML,expanded:trigger.expanded,focused:trigger.focused,status:status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
base = "api/v1/notifications/42/comments/91/reactions"
assert output["calls"] == [[base, "GET"], [base + "/heart", "PUT"]]
assert output["hidden"] is True
assert output["expanded"] == "false"
assert output["focused"] == 1
assert output["status"] == "Reaction updated."
def test_comment_reaction_wire_blocks_offline_mutation_with_reconnect_guidance():
module = json.dumps(str(ROOT / "frontend" / "comment-actions.js"))
script = f"""
const createActions=require({module});
let calls=0;
const menu={{hidden:true,innerHTML:''}};
const card={{dataset:{{commentId:'91'}},querySelector:s=>s.includes('menu')?menu:trigger}};
const trigger={{disabled:false,closest:s=>s==='[data-comment-reactions-open]'?trigger:(s==='.issue-comment'?card:null)}};
const root={{listeners:{{}},addEventListener:(n,fn)=>root.listeners[n]=fn}};
const status={{textContent:''}};
const actions=createActions({{fetchJson:async()=>{{calls++;}},getLogin:()=> 'timmy'}});
actions.wire({{root,getSurface:()=>({{context:{{kind:'issue',item:{{repository:'stackchain/api',number:7}}}},status}}),isOffline:()=>true,escapeHtml:String}});
(async()=>{{await root.listeners.click({{target:trigger}});process.stdout.write(JSON.stringify({{calls,status:status.textContent}}));}})();
"""
assert run_node(script) == {"calls": 0, "status": "Reconnect to react."}

View File

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

View File

@ -133,3 +133,75 @@ def test_comment_action_retry_is_a_phone_sized_inline_control():
rule = css.split(".conversation-actions-retry", 1)[1].split("}", 1)[0]
assert "min-height:44px" in rule
assert "max-width:100%" in rule
def test_search_and_following_preview_hydrates_owned_comment_actions_lazily():
preview = FRONTEND / "search-preview.js"
script = f"""
require({json.dumps(str(preview))});
const nodes = {{
'#search-preview-comments': {{innerHTML:'',textContent:''}},
'#search-preview-conversation-status': {{textContent:''}},
'#retry-search-preview-conversation': {{hidden:true}},
'#load-older-search-preview-comments': {{hidden:true,disabled:false}},
}};
const document = {{querySelector: selector => nodes[selector]}};
const actions = {{actionHtml: comment => comment.author === 'timmy'
? '<button data-comment-action="edit">Edit</button><button data-comment-reactions-open>React</button>'
: '<button data-comment-reactions-open>React</button>'}};
renderSearchPreviewConversation({{
status:'ready', reviewedAt:'2026-08-25T08:00:00Z',
comments:[
{{id:41,author:'timmy',body:'Mine',created_at:'2026-08-25T09:00:00Z'}},
{{id:42,author:'alexander',body:'Theirs',created_at:'2026-08-25T07:00:00Z'}},
],
}}, document, String, String, String, actions);
process.stdout.write(JSON.stringify({{html:nodes['#search-preview-comments'].innerHTML}}));
"""
output = run_node(script)["html"]
assert output.count('class="search-preview-comment issue-comment') == 2
assert output.count("data-comment-reactions-open") == 2
assert output.count('data-comment-action="edit"') == 1
assert 'data-comment-id="41"' in output
assert "new-since-review" in output
html = (FRONTEND / "index.html").read_text()
javascript = (FRONTEND / "dashboard.js").read_text()
assert 'id="retry-search-preview-comment-actions"' in html
assert "showSearchConversationWithActions" in javascript
assert "context:{kind:searchPreviewDetail.kind,item:searchPreviewDetail}" in javascript
def test_search_preview_comment_pager_updates_exact_visible_comment_in_place():
preview = FRONTEND / "search-preview.js"
script = f"""
const createSearchPreview = require({json.dumps(str(preview))});
(async () => {{
const states=[];
const controller=createSearchPreview({{
fetchJson:async item=>({{...item,title:'Visible'}}),
fetchConversation:async()=>({{comments:[
{{id:41,author:'timmy',body:'Old'}},{{id:42,author:'alexander',body:'Keep'}},
],older_page:null}}),
mutate:async()=>({{}}), onState:state=>states.push(state),
}});
await controller.open({{repository:'stackchain/api',number:9,kind:'pull'}});
await new Promise(resolve=>setTimeout(resolve,0));
const pager=controller.commentPager();
pager.replace({{id:41,author:'timmy',body:'Corrected'}});
const replaced=pager.snapshot().comments;
pager.remove(41);
process.stdout.write(JSON.stringify({{
replaced:replaced.map(comment=>[comment.id,comment.body]),
remaining:pager.snapshot().comments.map(comment=>comment.id),
published:states.at(-1).conversation.comments.map(comment=>comment.id),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"replaced": [[41, "Corrected"], [42, "Keep"]],
"remaining": [42],
"published": [42],
}

View File

@ -79,6 +79,50 @@ const photos = [
}
def test_store_lists_only_confirmed_accounts_conversation_photo_drafts_without_blobs():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = new Map([
['timmy:issue:stackchain%2Fdashboard%3A7', {{id:'a',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'stackchain/dashboard',number:7,updatedAt:100,attachments:[{{filename:'rack.jpg',blob:new Blob(['secret'])}},{{filename:'label.jpg',blob:new Blob(['secret'])}}]}},],
['timmy:update:8', {{id:'b',version:1,ownerLogin:'timmy',scope:'conversation',kind:'update',notificationId:8,updatedAt:200,attachments:[{{filename:'alert.jpg',blob:new Blob(['secret'])}}]}},],
['alexander:pull:o%2Fr%3A9', {{id:'c',version:1,ownerLogin:'alexander',scope:'conversation',kind:'pull',repository:'o/r',number:9,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},],
['broken', {{id:'broken',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'',number:0,updatedAt:400,attachments:[{{filename:'bad.jpg',blob:new Blob(['bad'])}}]}},],
]);
const transaction = async (operation, key, value) => {{
if (operation === 'list') return Array.from(records.values()).map(value => structuredClone(value));
}};
const store = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
(async()=>{{
const drafts=await store.list();
process.stdout.write(JSON.stringify(drafts));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == [
{
"id": "b",
"kind": "photo-reply",
"label": "Update photos",
"title": "Update #8",
"photo_count": 1,
"updated_at": 200,
"route": {"kind": "update", "notification_id": 8},
"photo_store": "conversation",
},
{
"id": "a",
"kind": "photo-reply",
"label": "Issue photos",
"repository": "stackchain/dashboard",
"number": 7,
"title": "stackchain/dashboard#7",
"photo_count": 2,
"updated_at": 100,
"route": {"kind": "issue", "repository": "stackchain/dashboard", "number": 7},
"photo_store": "conversation",
},
]
def test_store_isolates_today_progress_photos_from_conversation_photos():
script = f"""
const createStore = require({json.dumps(str(STORE))});
@ -109,6 +153,24 @@ const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
}
def test_coordinator_notifies_inventory_after_checkpoint_and_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const changes=[];
let current=[{{filename:'proof.jpg',blob:new Blob(['proof'])}}];
const store={{save:async()=>null,load:async()=>null,remove:async()=>null}};
const controller={{serialize:async()=>current,restore:()=>{{}},clear:()=>{{current=[];}}}};
const drafts=createCoordinator({{store,lanes:{{issue:{{controller}}}},onChange:()=>changes.push('changed')}});
(async()=>{{
await drafts.open('issue',{{kind:'issue',repository:'o/r',number:7}});
await drafts.checkpoint('issue');
await drafts.complete('issue');
process.stdout.write(JSON.stringify(changes));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == ["changed", "changed"]
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});

View File

@ -2,6 +2,7 @@ import asyncio
import sqlite3
import threading
import time
from typing import get_args
from urllib.parse import parse_qs, urlsplit
import httpx
@ -9,10 +10,18 @@ import pytest
from fastapi import Request
from src import main
from src.session_store import SessionStoreError
from src.session_store import SessionStatus, SessionStoreError
from src.views import FRONTEND_BUILD
def test_release_rollback_is_a_supported_step_up_action():
assert "prepare_release_rollback" in get_args(main.StepUpAction)
def test_ci_job_retry_is_a_supported_step_up_action():
assert "retry_ci_job" in get_args(main.StepUpAction)
@pytest.fixture
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@ -24,6 +33,83 @@ def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
async def upstream_user():
return {"id": 42, "login": "timmy"}
monkeypatch.setattr(main, "current_user", upstream_user)
def test_signed_session_carries_bound_upstream_identity(access_control):
signed, _ = main.dashboard_auth.issue_session(
now=1_000,
principal_id=42,
principal_login="timmy",
)
verification = main.dashboard_auth.verify_session_with_reason(signed, now=1_001)
assert verification.session is not None
assert verification.session.principal_id == 42
assert verification.session.principal_login == "timmy"
@pytest.mark.anyio
async def test_session_is_blocked_when_upstream_account_changes(
access_control, monkeypatch
):
identity = {"id": 42, "login": "timmy"}
async def upstream_user():
return dict(identity)
monkeypatch.setattr(main, "current_user", upstream_user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
identity.update(login="renamed-timmy")
same_principal = await client.get("/api/v1/security-events")
identity.update(id=84, login="other-operator")
protected = await client.get("/api/v1/security-events")
assert signed_in.status_code == 200
assert same_principal.status_code == 200
assert protected.status_code == 401
assert protected.json() == {
"detail": "Connected Gitea account changed; sign in again",
"code": "session_idle",
"reason": "upstream_identity_changed",
"previous_login": "timmy",
"current_login": "other-operator",
}
@pytest.mark.anyio
async def test_session_identity_lookup_outage_is_retryable(access_control, monkeypatch):
available = True
async def upstream_user():
if not available:
raise RuntimeError("credential lookup details")
return {"id": 42, "login": "timmy"}
monkeypatch.setattr(main, "current_user", upstream_user)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
available = False
protected = await client.get("/api/v1/security-events")
assert signed_in.status_code == 200
assert protected.status_code == 503
assert protected.json() == {"detail": "Gitea identity is temporarily unavailable"}
assert protected.headers["retry-after"] == "5"
assert "credential lookup" not in protected.text
async def fresh_grant(client, action: str, target: str) -> str:
@ -43,6 +129,237 @@ async def fresh_grant(client, action: str, target: str) -> str:
return response.json()["grant"]
@pytest.mark.anyio
async def test_ci_job_reruns_require_exact_one_time_authorization_and_audit_both_flows(
access_control, monkeypatch
):
lifecycle = []
class Journal:
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, principal_id, target):
assert principal_id == 42
operation_id = f"operation-{len(lifecycle)}"
lifecycle.append(("reserve", kind, target, operation_id))
return operation_id
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def capabilities(repository, number):
return {"authored": True, "assigned": False}
async def retry_pull(repository, number, head_sha, run_id, job_index):
lifecycle.append(("retry-pull", repository, number, head_sha, run_id, job_index))
return {"status": "queued"}
async def release_access(repository, number, commit_sha):
return True
async def retry_release(repository, commit_sha, run_id, job_index):
lifecycle.append(("retry-release", repository, commit_sha, run_id, job_index))
return {"status": "queued"}
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
monkeypatch.setattr(main, "_pull_workspace_capabilities", capabilities)
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry_pull)
monkeypatch.setattr(main.gitea_proxy, "can_recover_merged_release", release_access)
monkeypatch.setattr(main.gitea_proxy, "retry_release_action_job", retry_release)
transport = httpx.ASGITransport(app=main.app)
pull_path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
release_path = (
"/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234"
"/checks/91/jobs/3/retry"
)
pull_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
release_target = "stackchain/api#7@abc1234:actions/91/jobs/3"
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
assert signed_in.status_code == 200
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
missing = await client.post(
pull_path, json={"expected_head_sha": "abc1234"}, headers=headers
)
wrong_grant = await fresh_grant(
client, "retry_ci_job", "stackchain/api#7@different:actions/91/jobs/3"
)
mismatched = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": wrong_grant},
)
pull_grant = await fresh_grant(client, "retry_ci_job", pull_target)
retried_pull = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": pull_grant},
)
replayed = await client.post(
pull_path,
json={"expected_head_sha": "abc1234"},
headers={**headers, "X-Step-Up-Grant": pull_grant},
)
release_grant = await fresh_grant(client, "retry_ci_job", release_target)
retried_release = await client.post(
release_path,
headers={**headers, "X-Step-Up-Grant": release_grant},
)
assert missing.status_code == 428
assert missing.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "retry_ci_job",
"target": pull_target,
}
assert mismatched.status_code == 428
assert retried_pull.status_code == 202
assert replayed.status_code == 428
assert retried_release.status_code == 202
assert lifecycle == [
("reserve", "ci_job_retried", pull_target, "operation-0"),
("retry-pull", "stackchain/api", 7, "abc1234", 91, 3),
("finalize", "operation-0"),
("reserve", "ci_job_retried", release_target, "operation-3"),
("retry-release", "stackchain/api", "abc1234", 91, 3),
("finalize", "operation-3"),
]
@pytest.mark.anyio
async def test_ci_job_retry_fails_closed_when_security_activity_is_unavailable(
access_control, monkeypatch
):
retry_calls = []
class UnavailableJournal:
def reserve(self, *_args, **_kwargs):
from src.security_event_store import SecurityEventStoreError
raise SecurityEventStoreError("unavailable")
async def retry(*args):
retry_calls.append(args)
return {"status": "queued"}
transport = httpx.ASGITransport(app=main.app)
path = "/api/v1/repos/stackchain/api/pulls/7/checks/91/jobs/3/retry"
target = "stackchain/api#7@abc1234:actions/91/jobs/3"
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
grant = await fresh_grant(client, "retry_ci_job", target)
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
monkeypatch.setattr(main.gitea_proxy, "retry_action_job", retry)
response = await client.post(
path,
json={"expected_head_sha": "abc1234"},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
assert response.status_code == 503
assert response.json() == {
"error": "Security activity is temporarily unavailable. No job was retried."
}
assert retry_calls == []
@pytest.mark.anyio
async def test_source_branch_deletion_accepts_exact_one_time_fresh_authorization(
access_control, monkeypatch
):
lifecycle = []
class Journal:
def record(self, *_args, **_kwargs):
pass
def reserve(self, kind, *, principal_id, target):
assert principal_id == 42
lifecycle.append(("reserve", kind, target))
return "cleanup-operation"
def finalize(self, operation_id):
lifecycle.append(("finalize", operation_id))
def discard(self, operation_id):
lifecycle.append(("discard", operation_id))
async def cleanup(repository, number, source_branch, expected_head_sha):
lifecycle.append(
("delete", repository, number, source_branch, expected_head_sha)
)
return {"number": number, "deleted": True}
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
monkeypatch.setattr(main.gitea_proxy, "delete_merged_source_branch", cleanup)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
signed_in = await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
assert signed_in.status_code == 200
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
request = {
"source_branch": "timmy/feature",
"expected_head_sha": "abc1234",
}
path = "/api/v1/repos/stackchain/api/pulls/7/source-branch"
challenged = await client.request("DELETE", path, json=request, headers=headers)
grant = await fresh_grant(
client, "delete_source_branch", "stackchain/api#7@abc1234"
)
deleted = await client.request(
"DELETE",
path,
json=request,
headers={**headers, "X-Step-Up-Grant": grant},
)
replayed = await client.request(
"DELETE",
path,
json=request,
headers={**headers, "X-Step-Up-Grant": grant},
)
assert challenged.status_code == 428
assert challenged.json()["detail"] == {
"detail": "Fresh authorization required",
"code": "step_up_required",
"action": "delete_source_branch",
"target": "stackchain/api#7@abc1234",
}
assert deleted.status_code == 200
assert replayed.status_code == 428
assert lifecycle == [
("reserve", "source_branch_deleted", "stackchain/api#7@abc1234"),
("delete", "stackchain/api", 7, "timmy/feature", "abc1234"),
("finalize", "cleanup-operation"),
]
@pytest.mark.anyio
async def test_passkey_enrollment_options_require_fresh_authorization_and_are_one_time(
access_control,
@ -170,6 +487,56 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
assert "correct horse battery staple" not in signed_in.text
@pytest.mark.anyio
async def test_passkey_enrolled_for_another_upstream_principal_is_not_advertised(
access_control, monkeypatch
):
identity = {"id": 42, "login": "timmy"}
async def upstream_user():
return dict(identity)
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
monkeypatch.setattr(main, "current_user", upstream_user)
monkeypatch.setattr(
main.passkeys,
"verify_registration",
lambda **_kwargs: VerifiedRegistration(),
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as owner:
await owner.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": owner.cookies["stackchain_csrf"],
}
grant = await fresh_grant(owner, "enroll_passkey", "current_device")
options = await owner.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant},
)
enrolled = await owner.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
identity.update(id=84, login="other-operator")
async with httpx.AsyncClient(transport=transport, base_url="https://test") as other:
denied = await other.post("/api/v1/passkeys/authentication/options")
assert enrolled.status_code == 201
assert denied.status_code == 404
assert denied.json() == {"detail": "No passkeys enrolled"}
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
access_control, monkeypatch
@ -189,6 +556,7 @@ async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
principal_id=42,
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
@ -204,7 +572,7 @@ async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
},
)
events = main._security_event_store().list(limit=10).events
events = main._security_event_store().list(principal_id=42, limit=10).events
assert denied.status_code == 401
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
@ -231,6 +599,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
principal_id=42,
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
@ -258,7 +627,7 @@ async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
headers=headers,
)
events = main._security_event_store().list(limit=10).events
events = main._security_event_store().list(principal_id=42, limit=10).events
assert options.status_code == 200
assert denied.status_code == 401
assert "grant" not in denied.json()
@ -278,6 +647,7 @@ async def test_passkey_options_are_source_limited_before_challenge_generation(
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
principal_id=42,
)
first_source = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
other_source = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
@ -310,6 +680,7 @@ async def test_failed_passkey_verification_consumes_the_shared_sign_in_budget(
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
principal_id=42,
)
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.9", 1234))
@ -360,6 +731,7 @@ async def test_successful_passkey_sign_in_clears_prior_source_failures(
sign_count=0,
device_label="Phone",
management_id="phone-management-id",
principal_id=42,
)
source = "203.0.113.10"
attempts = main._login_attempt_store()
@ -553,6 +925,7 @@ async def test_device_revocation_failure_preserves_its_passkey_and_session(
sign_count=0,
device_label="Phone",
management_id=phone_device["management_id"],
principal_id=42,
)
grant = await fresh_grant(
laptop, "revoke_device", phone_device["management_id"]
@ -623,6 +996,7 @@ async def test_orphaned_passkey_is_listed_safely_and_can_be_selectively_revoked(
sign_count=0,
device_label="Backup key",
management_id="backup-management-id",
principal_id=42,
)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as laptop:
@ -703,6 +1077,7 @@ async def test_removing_a_remote_passkey_also_revokes_its_active_session(access_
sign_count=0,
device_label="Phone",
management_id=phone_device["management_id"],
principal_id=42,
)
grant = await fresh_grant(
laptop, "revoke_passkey", phone_device["management_id"]
@ -747,6 +1122,7 @@ async def test_removing_the_current_passkey_keeps_the_current_session_active(acc
sign_count=0,
device_label="Laptop",
management_id=current["management_id"],
principal_id=42,
)
grant = await fresh_grant(client, "revoke_passkey", current["management_id"])
removed = await client.delete(
@ -1718,7 +2094,7 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
grant = await fresh_grant(client, "revoke_all_sessions", "all")
class BrokenStore:
def revoke_all_access(self):
def revoke_all_access(self, *, principal_id):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main, "_passkey_store", lambda: BrokenStore())
@ -1755,6 +2131,7 @@ async def test_sign_out_all_devices_failure_preserves_passkeys_and_sessions(acce
sign_count=0,
device_label="Phone",
management_id=device["management_id"],
principal_id=42,
)
grant = await fresh_grant(client, "revoke_all_sessions", "all")
with sqlite3.connect(store.path) as connection:
@ -1821,7 +2198,7 @@ async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, mon
assert before.status_code == 200
assert after.status_code == 401
assert after.headers["cache-control"] == "no-store"
assert calls == 1
assert calls == 4
@pytest.mark.anyio
@ -1835,7 +2212,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
class SlowStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.15)
return "active"
return SessionStatus(
"active", principal_id=42, principal_login="timmy"
)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
@ -2001,7 +2380,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
class SlowStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.02)
return "active"
return SessionStatus(
"active", principal_id=42, principal_login="timmy"
)
def revoke(self, session_id):
time.sleep(0.15)
@ -2043,6 +2424,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
called = False
class BrokenStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
@ -2091,7 +2473,9 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
class BrokenStore:
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
return SessionStatus(
"active", principal_id=42, principal_login="timmy"
)
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")

View File

@ -558,6 +558,28 @@ process.stdout.write(JSON.stringify(state));
assert result["replacedAfterDeletion"] is True
def test_upstream_identity_change_preserves_private_work_and_requires_sign_in():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {
detail:'Connected Gitea account changed; sign in again',
code:'session_idle', reason:'upstream_identity_changed',
previous_login:'timmy', current_login:'other-operator',
};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["workerMessages"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_idle_response_locks_without_clearing_private_queued_work():
result = run_session_scenario(
"""

View File

@ -130,7 +130,8 @@ def test_detail_watch_is_wired_into_both_mobile_work_details_and_bundle():
assert 'pullDetailWatch.open(item)' in dashboard
assert '#watch-issue-detail' in css
assert '#watch-pull-detail' in css
assert 'tests/e2e/test_mobile_detail_watch_release.py' in workflow
assert "python3 -m pytest tests/e2e -q" in workflow
assert (ROOT / "tests/e2e/test_mobile_detail_watch_release.py").is_file()
def test_detail_watch_ignores_a_late_subscription_response_for_the_previous_item():

View File

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

View File

@ -275,7 +275,7 @@ const feature=createFollowing({{
]
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
def test_following_opens_explicitly_and_becomes_work_recommendation():
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
script = f"""
const createLauncher = require({json.dumps(str(launcher))});
@ -293,7 +293,7 @@ process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommend
).stdout)
assert result == {
"opened": "opened-following",
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
"recommended": {"name": "following", "count": 7, "label": "Review Following (7)"},
"calls": ["following"],
}
@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
assert ".following-disposition-mode" in css
assert "if (searchPreviewReturnKind === 'following')" in dashboard
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
assert "stackchain-dashboard-shell-v138" in service_worker
assert "stackchain-dashboard-shell-v150" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -33,6 +33,15 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
initial_mobile_javascript = (
len(first.runtime_gzip_bytes)
+ len(first.feature_bundles["work-core"].runtime_gzip_bytes)
)
assert initial_mobile_javascript <= 60 * 1024
assert (
len(first.feature_bundles["today-timer"].runtime_gzip_bytes)
+ len(first.feature_bundles["planning"].runtime_gzip_bytes)
) > initial_mobile_javascript
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
@ -50,7 +59,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert set(first.feature_bundles) == {
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
"today-timer", "security-center", "planning",
"work-core", "today-timer", "security-center", "planning",
}
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
@ -95,15 +104,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
shell_block, optional_block = first.service_worker_source.split(
"const OPTIONAL_FEATURES = [", 1
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
demand_loaded = {"today-timer", "planning"}
for name, bundle in first.feature_bundles.items():
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
if name in demand_loaded:
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
@ -127,14 +139,33 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
def test_my_work_has_a_small_blocking_bundle_before_optional_workspace_hydration():
build = build_frontend(FRONTEND)
work_core = build.feature_bundles["work-core"]
today = build.feature_bundles["today-timer"]
assert b"function buildMyWork" in work_core.runtime_bytes
assert b"function createProgressiveMyWork" in work_core.runtime_bytes
assert b"function createProgressiveCapture" in work_core.runtime_bytes
assert b"function createUnfiledCaptures" in work_core.runtime_bytes
assert b"function buildMyWork" not in today.runtime_bytes
assert b"function createProgressiveCapture" not in today.runtime_bytes
assert b"function createUnfiledCaptures" not in today.runtime_bytes
assert b"loadWorkspace({document,window})" in build.runtime_bytes
assert b"const workspaceLifecycle" not in build.runtime_bytes
assert b"const workspaceLifecycle" in today.runtime_bytes
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [work_core.runtime_name]
assert len(build.runtime_gzip_bytes) + len(work_core.runtime_gzip_bytes) <= 150 * 1024
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
build = build_frontend(FRONTEND)
workspace = build.feature_bundles["today-timer"]
workspace = build.feature_bundles["work-core"]
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
assert len(build.runtime_gzip_bytes) <= 100 * 1024
assert len(workspace.runtime_gzip_bytes) <= 111 * 1024
assert len(build.runtime_gzip_bytes) + len(workspace.runtime_gzip_bytes) <= 150 * 1024
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
assert f"BASE + '{workspace.runtime_name}'" not in shell_block

View File

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

View File

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

View File

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

View File

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

View File

@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v138" in source
assert "stackchain-dashboard-shell-v150" in source
assert "BASE + 'static/later-sync.js'" in source

View File

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

View File

@ -54,7 +54,7 @@ process.stdout.write(JSON.stringify({{ status: status.textContent }}));
)
assert json.loads(result.stdout)["status"] == (
"Stackchain locked after inactivity. Your drafts and queued work are still "
"Stackchain locked. Your drafts and queued work are still "
"on this device. Sign in to resume."
)

View File

@ -221,7 +221,7 @@ def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
dashboard = (FRONTEND / "dashboard.js").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text() + (FRONTEND / "conversation.js").read_text()
expected_paths = (
"renderMarkdown(comment.body || 'No comment body provided.')",
@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v150" in worker

View File

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

View File

@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v150" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "controller.recoverPermission('deadline')" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v150" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css

View File

@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
def test_mobile_insights_rolls_into_the_offline_shell():
worker = (CONTROLLER.parent / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v138" in worker
assert "stackchain-dashboard-shell-v150" in worker
assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -96,7 +96,13 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert "mobileAppShortcuts.createController" in bootstrap
assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
adopted = bootstrap.index(
"let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);"
)
fallback = bootstrap.index("if (!adoptedProgressiveSnapshot) await load();")
shortcuts = bootstrap.index("await appShortcut.run();")
assert adopted < fallback < shortcuts
assert "contextPoller.start();" not in bootstrap
assert "mobileLaunch.chooseFilter" in html
assert "agenda: counts.agenda" in html
assert "mobileLaunch.createDisclosure" in html

View File

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

Some files were not shown because too many files have changed in this diff Show More