Compare commits

...
Author SHA1 Message Date
rockachopa 1e9c3444fa Merge pull request 'Refresh Human Gates atomically on every reopen' (#1434) from timmy/1433-refresh-human-gates-reopen into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 8m15s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 4m10s
2026-08-26 14:03:30 +00:00
timmy 2e9d108c6f feat: refresh Human Gates review sessions (Closes #1433)
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
rockachopa 7dc32138e4 Merge pull request 'Recover progressive mobile identity after partial live snapshots' (#1432) from timmy/1431-recover-progressive-mobile-identity into main
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 7m40s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 3m58s
2026-08-26 12:55:55 +00:00
timmy 26288d364c feat: recover progressive identity after partial snapshots
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
timmy 97a36ea22f Merge pull request 'Preserve healthy live sections during partial upstream timeouts' (#1430) from timmy/1429-section-isolated-live-snapshot-deadlines into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m19s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m59s
2026-08-26 11:55:23 +00:00
timmy dc9e64e98e feat: isolate live snapshot section deadlines (Closes #1429)
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
timmy 065cc46553 Merge pull request 'Make mobile Work and Queues usable before optional hydration' (#1428) from timmy/1427-progressive-mobile-dock into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m12s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m51s
Merge pull request #1428: progressive mobile dock
2026-08-26 10:40:10 +00:00
timmy 664317212d test: gate progressive dock browser journey
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
timmy 62de2e6011 feat: make mobile dock progressive (Closes #1427)
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
timmy 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
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m48s
2026-08-26 09:31:18 +00:00
timmy 15d7bc9234 feat: share progressive live snapshot (Closes #1425)
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
rockachopa bea0e0e429 Merge pull request 'Adaptive mobile Queues next-up flow' (#1424) from timmy/1423-adaptive-mobile-queues into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m57s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m47s
2026-08-26 07:43:07 +00:00
timmy b7d496600e test: exercise collapsed all queues
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
timmy 8b2fd305ca feat: prioritize adaptive mobile queues
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
timmy 1e8d9a27be Merge pull request 'Fresh-authorize and audit Human Gate decisions' (#1422)
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 8m50s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m53s
Closes #1421
2026-08-26 06:22:50 +00:00
timmy 1c66905b4a feat: fresh-authorize and audit Human Gate decisions
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
timmy 4319c161cb Merge pull request 'Open Human Gates before optional workspace hydration' (#1420) from timmy/1419-open-human-gates-before-optional-hydration into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 7m21s
CI / release-candidate (push) Successful in 9s
CI / lint (push) Successful in 4m22s
2026-08-26 05:05:08 +00:00
timmy 5ec4dd7f6e feat: open Human Gates before optional hydration
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
timmy 718f0d1209 feat: surface Human Gates in mobile preparation (#1418)
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 8m3s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 4m11s
Closes #1417
2026-08-26 03:42:45 +00:00
rockachopa 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
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m54s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 4m13s
2026-08-26 02:09:24 +00:00
timmy af16f987f1 fix: restore timed break after identity hydration
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
timmy f4d6f9176c docs: record Human Gates identity invariants
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
timmy ab329aa126 fix: isolate overlapping Human Gate loads
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
timmy 9d84d5c5c4 fix: close Human Gates review races
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
timmy dbb8ff4d4e fix: harden Human Gates review flow
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
timmy 364fc60116 test: show Human Gate project in review detail
CI / lint (pull_request) Successful in 4m5s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m2s
CI / release-candidate (pull_request) Has been skipped
2026-08-26 00:10:27 +00:00
timmy e02115bd71 Add durable Human Gates inbox 2026-08-26 00:05:27 +00:00
rockachopa 7ec78ac182 Merge pull request 'Open progressive mobile My Work in-app before hydration' (#1414) from timmy/1413-progressive-my-work-detail into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m53s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m53s
2026-08-25 23:43:17 +00:00
timmy 94728d81ff feat: open progressive My Work in app (Closes #1413)
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
rockachopa 7960f14b5b Merge pull request 'Keep progressive mobile My Work live through delayed hydration' (#1412) from timmy/1411-progressive-my-work-live-recovery into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m23s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m50s
2026-08-25 22:44:51 +00:00
timmy f9b29d8d14 feat: keep progressive My Work live (Closes #1411)
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
rockachopa e7e8c24d05 Recover progressive mobile captures after reload (#1410)
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 8m13s
CI / release-candidate (push) Successful in 11s
CI / lint (push) Successful in 4m18s
Closes #1409
2026-08-25 21:41:01 +00:00
timmy 8b56618e62 feat: recover progressive mobile captures (Closes #1409)
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
timmy dcad1da1da Merge pull request 'Enable mobile capture before optional workspace hydration' (#1408) from timmy/1407-progressive-mobile-capture into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m39s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m35s
2026-08-25 20:37:00 +00:00
timmy 8bb8fb0cd9 feat: enable progressive mobile capture
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
rockachopa a23276f0b8 Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406) from timmy/1405-progressive-live-snapshot-handoff into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m39s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m42s
Merge pull request 'Reuse the progressive live snapshot during mobile hydration' (#1406)
2026-08-25 19:25:21 +00:00
timmy 54cb078d1a perf: reuse progressive live snapshot during hydration (Closes #1405)
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
rockachopa 1822004f3d Merge pull request 'Preserve mobile My Work queue truth through hydration' (#1404) from timmy/1403-progressive-queue-handoff into main
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Failing after 7m21s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m32s
2026-08-25 18:14:32 +00:00
timmy c15f7768e4 feat: preserve progressive My Work queue truth (Closes #1403)
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
rockachopa 024087d051 Merge pull request 'Hydrate mobile My Work before optional tools' (#1402) from timmy/1401-progressive-my-work-hydration into main
CI / build-release (push) Successful in 10s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m32s
2026-08-25 16:56:49 +00:00
timmy 297ae7550a feat: hydrate mobile My Work before optional tools
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
rockachopa e44f49708a Merge pull request 'Fix 320px active-Today week reschedule completion' (#1400) from timmy/1398-320-active-today-week-reschedule-dialog into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m45s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m35s
2026-08-25 15:34:32 +00:00
timmy 3e7d758782 test: cover reschedule retry recovery
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
timmy 185f3f9706 fix: close mobile week reschedule while saving
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
rockachopa 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
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m26s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m30s
2026-08-25 14:12:39 +00:00
timmy d273de3506 fix: preserve completed mobile onboarding during draft sync
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
timmy fa876e1e38 feat: surface photo-only reply drafts in My Work (Closes #1396)
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
rockachopa 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
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m25s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 3m37s
2026-08-25 12:22:32 +00:00
timmy 978351dff2 fix: preserve active break over reload checkpoint (Closes #1394)
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
rockachopa 553a01217a Merge pull request 'Schedule mobile notification quiet hours with one catch-up digest' (#1393) from timmy/1392-mobile-notification-quiet-hours into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 6m30s
CI / release-candidate (push) Has been skipped
CI / lint (push) Successful in 3m47s
2026-08-25 11:28:45 +00:00
timmy cc74b7b8db feat: schedule mobile notification quiet hours (Closes #1392)
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
timmy 2acd9431e0 Merge pull request 'Keep mobile Search and Preview focus-contained through Back navigation' (#1391) from timmy/1390-mobile-search-modal-focus into main
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 3m26s
2026-08-25 10:20:10 +00:00
timmy 4855bc7c8d feat: contain mobile Search focus through Preview (Closes #1390)
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
rockachopa 6e19639b19 Merge pull request 'Make Search and Following conversations actionable on mobile' (#1389) from timmy/1388-search-following-comment-actions into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m29s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m32s
2026-08-25 09:22:04 +00:00
timmy 7662cca9e6 feat: act on Search and Following comments (Closes #1388)
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
rockachopa 0682bbcc2c Merge pull request 'Apply reviewer suggestions across files atomically' (#1387) from timmy/1386-atomic-multi-file-review-suggestions into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m21s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m34s
2026-08-25 08:12:56 +00:00
timmy 1c02dcb217 feat: apply review suggestions across files
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
rockachopa 7bb22e3525 Merge pull request 'Apply same-file review suggestions as one atomic mobile fix' (#1385) from timmy/1384-atomic-review-suggestion-batch into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m9s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m40s
2026-08-25 07:26:00 +00:00
timmy 869543e992 feat: batch mobile review suggestions (Closes #1384)
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
timmy c8ffde7d96 Merge pull request 'Apply reviewer code suggestions from mobile' (#1383) from timmy/1382-mobile-review-suggestions into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m22s
2026-08-25 06:19:21 +00:00
timmy ed662c3a3a feat: apply mobile review suggestions (Closes #1382)
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
timmy 8a881419db Merge pull request 'React to conversation comments without leaving mobile work' (#1381) from timmy/1380-mobile-comment-reactions into main
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 6m3s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m26s
2026-08-25 05:21:13 +00:00
timmy b4f3c6d419 feat: react to mobile conversation comments (Closes #1380)
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
rockachopa fc7a83fbd7 Merge pull request 'Pin release pipeline actions and enforce least-privilege permissions' (#1379) from timmy/1378-pin-release-actions into main
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 5m27s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 3m5s
2026-08-25 04:21:41 +00:00
timmy 22fe5dd1d8 ci: pin release pipeline actions (Closes #1378)
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
86 changed files with 7829 additions and 262 deletions

No files matched your search

+12 -9
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
@@ -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
+25 -1
View File
@@ -18,7 +18,14 @@ 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
@@ -844,3 +851,20 @@ 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 return durable receipts. 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.
+41
View File
@@ -0,0 +1,41 @@
# 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`.
## Telegram coalescing contract
Telegram or lock-screen adapters MUST coalesce pending changes per authenticated account and 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.
+95 -2
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);
+49 -6
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;
}
@@ -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 {
+7 -2
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 {
+39 -3
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 };
};
});
+33
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;
+55 -2
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; }
@@ -1179,6 +1212,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; }
@@ -1457,7 +1496,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; }
@@ -1470,9 +1509,19 @@ 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-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; }
@@ -1573,3 +1622,7 @@ 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-detail>div{display:grid;grid-template-columns:1fr 1fr;gap:10px}.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}
@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)}}
+168 -53
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,7 @@
}
let queueCounts = {};
let preparationItems = {};
let renderMobileQueuePresentation = () => {};
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
@@ -114,11 +122,13 @@
queueCounts.following = count;
queueCounts.followingUnavailable = false;
preparationItems.following = items.filter(item => item.has_unseen_change === true);
renderMobileQueuePresentation();
mobileStartDay.render();
},
onStatus:status => {
if (status === 'loading') return;
queueCounts.followingUnavailable = status === 'error';
renderMobileQueuePresentation();
mobileStartDay.render();
},
onReviewComplete:() => mobileStartDay.completePhase('following'),
@@ -159,6 +169,7 @@
}
const mobileQueueLauncher = createMobileQueueLauncher({
openDelivery: () => mobileDeliveryRecovery.open(),
openHumanGates: () => openHumanGates(),
openToday: () => mobileWorkEntry.open(),
openAgenda: openAgendaSession,
openUpdates: openUpdateTriage,
@@ -169,7 +180,18 @@
announce: announceWork,
getCounts: () => queueCounts,
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();
renderMobileQueuePresentation();
qs('#mobile-queue-next-action').addEventListener('click', () => mobileQueueLauncher.continueWork());
function openMobileStartDay() {
followingQueue.load().catch(() => {});
const state = mobileStartDay.state();
@@ -287,6 +309,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]'),
@@ -320,15 +348,17 @@
let offlineWorkMode = 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,6 +416,7 @@
let editingOutboxId = null;
let confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
let rR = null;
function rRC() {
@@ -628,6 +659,59 @@
return payload;
}
const humanGatesOnChange = (snapshot, state)=>{
queueCounts.gate = snapshot.pending_count;
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'),
},
});
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-gate-detail').addEventListener('click', event => {
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
const detail = qs('#human-gate-detail');
const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
humanGates.decideAndNext(decision, {
checklist,
reason:detail.querySelector('[data-gate-reason]')?.value || '',
override_reason:detail.querySelector('[data-gate-override]')?.value || '',
}).catch(error => { 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;
@@ -777,6 +861,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.';
@@ -1002,6 +1087,7 @@
} });
const conversationPhotoDrafts = createConversationPhotoDrafts({
store:conversationPhotoDraftStore,
onChange:() => { void refreshPhotoDraftInbox(); },
lanes:{
issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'),
pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'),
@@ -1310,6 +1396,10 @@
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();
@@ -3401,12 +3491,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, {
@@ -3432,8 +3527,11 @@
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
counts.delivery = draftInbox.partition(lastDrafts).actionable;
counts.gate = queueCounts.gate;
counts.gateUnavailable = queueCounts.gateUnavailable;
preparationItems = {
delivery:draftInbox.partition(lastDrafts).deliveries,
gate:preparationItems.gate || [],
agenda:agendaMyWork(activeMyWork),
attention:activeMyWork.filter(item => item.needs_attention),
update:activeMyWork.filter(item => item.has_update),
@@ -3454,13 +3552,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 + '"]');
@@ -3579,7 +3678,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>';
};
@@ -3618,6 +3717,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);
};
});
@@ -3736,6 +3838,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"]');
@@ -4097,46 +4200,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'),
@@ -5522,6 +5596,7 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
void refreshPhotoDraftInbox();
timerView.restore(todaySync.flush());
restoreReleaseReceipt();
updateDeliveryReceiptControls();
@@ -5533,6 +5608,9 @@
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();
@@ -5664,8 +5742,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');
@@ -5878,6 +5962,7 @@
mediaQuery: window.matchMedia('(max-width: 600px)'),
schedule: callback => requestAnimationFrame(callback),
});
const searchModal = createMobileSearchModal({document});
function closeSearchPreview(navigate = true) {
if (searchPreviewReturnKind === 'following') {
searchPreview.close();
@@ -5893,7 +5978,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) {
@@ -5915,6 +6000,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,
});
@@ -5961,7 +6047,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);
}
@@ -5980,6 +6069,7 @@
else {
searchPreview.close();
mobileSearchViewport.close();
searchModal.deactivate();
}
searchPreviewReturnKind = null;
}
@@ -5987,7 +6077,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 });
@@ -6015,6 +6105,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);
@@ -6418,6 +6512,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);
@@ -7828,6 +7928,8 @@
confirmedOwnerLogin = String(saved.user?.login || '').trim();
restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin;
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
interruptionPrompt.restore();
updatePlanningAvailability();
syncPendingTomorrow();
@@ -8433,9 +8535,22 @@
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();
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();
+232
View File
@@ -0,0 +1,232 @@
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;
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 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 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 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 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) + '" rel="noreferrer">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" rel="noreferrer">' + 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"> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"> Artifacts reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason></textarea></label>' +
'<label>Override reason<textarea data-gate-override></textarea></label>' +
'<div><button type="button" data-gate-decision="release">Release &amp; next</button>' +
'<button type="button" data-gate-decision="hold">Hold &amp; next</button></div></article>'
);
}
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() };
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);
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 };
}
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) throw new Error('Complete the release checklist before deciding.');
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
throw new Error('An explicit override reason is required for unmet required checks.');
}
if (decision === 'hold' && !String(values.reason || '').trim()) throw new Error('A hold reason is required.');
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 receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key },
body: JSON.stringify(payload),
});
decisionKeys.delete(decisionKey.operation);
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 };
})();
decisionFlight = operation;
try {
return await operation;
} finally {
if (decisionFlight === operation) decisionFlight = null;
}
}
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
if (openFlight) return openFlight;
const operation = (async () => {
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, decideAndNext, current,
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;
+100 -18
View File
@@ -187,7 +187,16 @@
<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 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 +233,12 @@
<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-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 +384,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 +808,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 +929,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>
@@ -1741,7 +1770,35 @@
<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>
<button id="make-pull-feedback-fix" type="button" hidden>Make code change</button>
<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>
@@ -2117,22 +2174,39 @@
<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">Next up</p>
<button id="mobile-queue-next-action" type="button">Find Work</button>
</section>
<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>
@@ -2229,6 +2303,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>
@@ -2248,6 +2323,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>
@@ -2338,6 +2417,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>
@@ -2349,6 +2429,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>
+58 -1
View File
@@ -10,6 +10,7 @@
};
const continuation = [
['delivery', 'Recover Delivery'],
['gate', 'Review Human Gates'],
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
@@ -18,6 +19,11 @@
['later', 'Start Later'],
['draft', 'Open Drafts'],
];
const activeQueueNames = continuation.map(([name]) => name).concat(['following', 'authored']);
const allQueues = [
'today', 'tomorrow', 'week', 'agenda', 'delivery', 'gate', 'attention',
'update', 'following', 'filed', 'authored', 'later', 'draft', 'find', 'recaps',
];
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
@@ -28,8 +34,59 @@
return {name, count, label: label + ' (' + count + ')'};
}
function presentation() {
const counts = options.getCounts ? options.getCounts() : {};
const active = activeQueueNames
.map(name => ({name, count: Math.max(0, Number(counts[name]) || 0)}))
.filter(item => item.count > 0);
activeQueueNames.forEach(name => {
if (counts[name + 'Unavailable'] && !active.some(item => item.name === name)) {
active.push({name, unavailable: true});
}
});
return {
nextUp: recommend(),
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;
options.nextAction.setAttribute('aria-label', 'Next up: ' + 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();
@@ -54,5 +111,5 @@
return open(next.name);
}
return { open, recommend, continueWork };
return { open, recommend, presentation, renderPresentation, continueWork };
});
+83
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 };
};
});
+12 -2
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' :
+3 -2
View File
@@ -97,9 +97,9 @@
}
function updateQueues(counts) {
const names = 'today agenda delivery attention update filed later draft'.split(' ');
const names = 'today agenda delivery gate attention update filed 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', 'filed', '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);
@@ -124,6 +124,7 @@
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Delivery ' + normalized.delivery
+ ', Human Gates ' + normalized.gate
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Filed ' + normalized.filed
+80
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 };
};
});
+182
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 you’re 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;
+101
View File
@@ -0,0 +1,101 @@
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'),
};
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 decision = event.target?.closest?.('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
const checklist = Object.fromEntries(Array.from(nodes.detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
controller.decideAndNext(decision, {
checklist,
reason:nodes.detail.querySelector?.('[data-gate-reason]')?.value || '',
override_reason:nodes.detail.querySelector?.('[data-gate-override]')?.value || '',
}).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;
}
+49
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;
+119
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();
}
+278
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;
+227 -1
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';
@@ -434,6 +472,14 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
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.';
@@ -474,17 +520,171 @@ 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;
@@ -495,6 +695,7 @@ function bindFeedbackControls(doc, controller, getSelected, getDetail, getLogin)
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';
@@ -858,6 +1059,31 @@ function createPullSheet({ fetchJson, storage, onState, createConversationPager
}).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', {
+45 -1
View File
@@ -5,6 +5,10 @@
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
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,
}) {
@@ -302,6 +306,36 @@
}
}
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();
@@ -340,6 +374,9 @@
deadlineControl?.addEventListener('change', changeDeadline);
startDayControl?.addEventListener('change', changeStartDay);
followingControl?.addEventListener('change', changeFollowing);
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 +384,7 @@
if (deadlineControl) deadlineControl.disabled = true;
if (startDayControl) startDayControl.disabled = true;
if (followingControl) followingControl.disabled = true;
if (quietControl) quietControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
@@ -354,6 +392,9 @@
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 (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 +408,11 @@
if (followingStatus) followingStatus.textContent = configuration.following_enabled
? 'Following change alerts enabled for this device.'
: 'Following change 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, changeQuietHours, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
});
+36 -2
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?.());
},
+28 -3
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 };
};
});
+9 -1
View File
@@ -1,7 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/private-data-registry.js');
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v139';
const CACHE = 'stackchain-dashboard-shell-v145';
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;
@@ -151,6 +151,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 +169,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 +188,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',
@@ -283,6 +290,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',
+4 -3
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) {
+2 -2
View File
@@ -337,10 +337,9 @@ 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();
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,6 +347,7 @@ function mountTodayWeekReschedule({
}
await continueToday();
}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';
+81 -47
View File
@@ -2,76 +2,110 @@ 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});
const attempts = Object.fromEntries(names.map(name => [name, 0]));
const failed = new Set();
const recoveries = new Map();
let retryInFlight = null;
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 loadFeature = name => {
attempts[name] += 1;
urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : '');
return loader.load(name);
};
const waitForRecovery = () => new Promise(resolve => {
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
if (retryButton) {
retryButton.hidden = retryButton.disabled = false;
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);
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();
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}));
}
});
const optionalReady = Promise.all(optional).then(() => {
hideRecovery();
return true;
});
window?.removeEventListener('online', captureOnline);
if (retryButton) retryButton.hidden = true;
if (status) status.textContent = '';
return {
optionalReady,
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;
+10 -6
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-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>"
+204
View File
@@ -1381,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",
@@ -2339,6 +2419,113 @@ async def commit_authored_pull_feedback_fix(
}
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:
@@ -3538,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]:
@@ -3554,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
+330
View File
@@ -0,0 +1,330 @@
"""Durable, account-bound human release gate inbox."""
from __future__ import annotations
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 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 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"]),
})
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)
def list(self, login: str, *, state: str = "pending", limit: int = 100) -> dict:
login = self._login(login)
if state not in _STATES and state != "all":
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]
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, priority DESC, created_at, 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])
+444 -17
View File
@@ -42,6 +42,12 @@ from src.gitea_proxy import (
repos,
)
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.human_gate_store import (
GateConflict,
GateNotFound,
GateValidationError,
HumanGateStore,
)
from src.image_sanitizer import sanitize_image
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import (
@@ -280,6 +286,7 @@ app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
CONTEXT_TIMEOUT_SECONDS = 5.0
LIVE_SNAPSHOT_DEADLINE_GRACE_SECONDS = 0.1
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
READINESS_INTERVAL_SECONDS = max(
@@ -441,6 +448,14 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload."""
class HumanGateDecision(BaseModel):
expected_revision: PositiveInt
decision: Literal["release", "hold"]
reason: str = Field(default="", max_length=2_000)
override_reason: str = Field(default="", max_length=2_000)
checklist: dict[str, bool]
async def _upstream_identity() -> tuple[int, str]:
upstream = await current_user()
principal_id = upstream.get("id") if isinstance(upstream, dict) else None
@@ -522,7 +537,32 @@ class FollowingNotificationPayload(BaseModel):
enabled: bool
class QuietHoursPayload(BaseModel):
enabled: bool
start: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
end: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
timezone: str = Field(min_length=1, max_length=64)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as error:
raise ValueError("Valid IANA timezone required") from error
return value
@model_validator(mode="after")
def distinct_boundaries(self):
if self.start == self.end:
raise ValueError("quiet hours require distinct start and end times")
return self
StepUpAction = Literal[
"decide_human_gate",
"merge_pull",
"delete_source_branch",
"prepare_release_rollback",
@@ -565,6 +605,17 @@ class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
pass
def _human_gate_store() -> HumanGateStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
return HumanGateStore(
os.getenv(
"STACKCHAIN_HUMAN_GATE_DB",
os.path.join(state_dir, "human-gates.sqlite3"),
),
clock=time.time,
)
def _passkey_store() -> PasskeyStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
database = os.getenv(
@@ -987,6 +1038,10 @@ class IssueComment(BaseModel):
return value
class CommentReactionState(BaseModel):
active: bool
def _validate_attachment_metadata(filename: str, content_type: str) -> None:
expected = {
"image/png": {"png"},
@@ -1162,6 +1217,30 @@ class PullFeedbackFileUpdate(BaseModel):
expected_blob_sha: str = Field(min_length=1, max_length=128)
class PullFeedbackBatchFile(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content: str = Field(max_length=131_072)
expected_blob_sha: str = Field(min_length=1, max_length=128)
class PullFeedbackBatchUpdate(BaseModel):
files: list[PullFeedbackBatchFile] = Field(min_length=1, max_length=8)
suggestion_count: int = Field(ge=2, le=8)
message: str = Field(min_length=1, max_length=120)
expected_head_sha: str = Field(
min_length=7, max_length=64, pattern=r"^[A-Fa-f0-9]+$"
)
@model_validator(mode="after")
def validate_file_manifest(self):
paths = [file.path for file in self.files]
if len(set(paths)) != len(paths):
raise ValueError("feedback batch file paths must be unique")
if len(paths) > self.suggestion_count:
raise ValueError("feedback batch cannot contain more files than suggestions")
return self
class PullContentUpdate(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
@@ -1667,7 +1746,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -1739,6 +1818,138 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "stackchain-dashboard"}
async def _human_gate_login(request: Request) -> str:
session = getattr(request.state, "dashboard_session", None)
if session is not None and session.principal_login and session.principal_id:
return f"{session.principal_id}:{session.principal_login}"
principal_id, login = await _upstream_identity()
return f"{principal_id}:{login}"
def _gate_error(error: Exception) -> HTTPException:
if isinstance(error, sqlite3.Error):
return HTTPException(status_code=503, detail="Human Gates are temporarily unavailable")
if isinstance(error, GateNotFound):
return HTTPException(status_code=404, detail=str(error))
if isinstance(error, GateConflict):
return HTTPException(status_code=409, detail=str(error))
return HTTPException(status_code=422, detail=str(error))
@app.post("/api/v1/human-gates/intake")
async def intake_human_gate(
payload: dict,
request: Request,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
):
login = await _human_gate_login(request)
try:
store = _human_gate_store()
existed = await asyncio.to_thread(store.has_intake_key, login, idempotency_key)
gate = await asyncio.to_thread(
store.intake, login, payload, idempotency_key=idempotency_key
)
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(
gate, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/human-gates")
async def list_human_gates(
request: Request,
state: str = Query(default="pending"),
limit: int = Query(default=100, ge=1, le=100),
):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit)
except (GateValidationError, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/human-gates/{gate_id}")
async def human_gate_detail(gate_id: str, request: Request):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().detail, login, gate_id)
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/human-gates/{gate_id}/decision")
async def decide_human_gate(
gate_id: str,
payload: HumanGateDecision,
request: Request,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
await _require_step_up(
request,
step_up_grant,
action="decide_human_gate",
target=gate_id,
)
login = await _human_gate_login(request)
journal = _security_event_store()
operation_id = None
try:
store = _human_gate_store()
existed = await asyncio.to_thread(store.has_receipt_key, login, idempotency_key)
if not existed:
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"human_gate_decision",
method=payload.decision,
target=gate_id,
)
except SecurityEventStoreError as error:
raise HTTPException(
status_code=503,
detail="Security activity is temporarily unavailable",
) from error
receipt = await asyncio.to_thread(
store.decide,
login,
gate_id,
expected_revision=payload.expected_revision,
decision=payload.decision,
reason=payload.reason,
override_reason=payload.override_reason,
checklist=payload.checklist,
idempotency_key=idempotency_key,
)
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
if operation_id is not None:
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
raise _gate_error(error) from error
if operation_id is not None:
await asyncio.to_thread(journal.finalize, operation_id)
return JSONResponse(
receipt, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/human-gate-receipts/{receipt_id}")
async def human_gate_receipt(receipt_id: str, request: Request):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().receipt, login, receipt_id)
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/session")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
peer_host = request.client.host if request.client is not None else "unknown"
@@ -2501,6 +2712,9 @@ async def push_status(request: Request):
following_preferences = await asyncio.to_thread(
_push_subscription_store.following_preferences, device_id
)
quiet_hours = await asyncio.to_thread(
_push_subscription_store.quiet_hours, device_id
)
delivery_health = await asyncio.to_thread(
_push_subscription_store.delivery_health, device_id
)
@@ -2517,6 +2731,10 @@ async def push_status(request: Request):
"start_day_timezone": start_day_preferences["timezone"],
"start_day_reminder_hour": start_day_preferences["reminder_hour"],
"following_enabled": following_preferences["enabled"],
"quiet_hours_enabled": quiet_hours["enabled"],
"quiet_hours_start": quiet_hours["start"],
"quiet_hours_end": quiet_hours["end"],
"quiet_hours_timezone": quiet_hours["timezone"],
"delivery_health": delivery_health,
}
@@ -2684,6 +2902,31 @@ async def update_following_notifications(
return {"following_enabled": payload.enabled}
@app.put("/api/v1/push-subscription/quiet-hours")
async def update_quiet_hours(payload: QuietHoursPayload, request: Request):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
if payload.enabled and not await asyncio.to_thread(
_push_subscription_store.is_subscribed, device_id
):
raise HTTPException(status_code=409, detail="Enable device notifications first")
await asyncio.to_thread(
_push_subscription_store.set_quiet_hours,
device_id,
enabled=payload.enabled,
start=payload.start,
end=payload.end,
timezone=payload.timezone,
)
return {
"quiet_hours_enabled": payload.enabled,
"quiet_hours_start": payload.start,
"quiet_hours_end": payload.end,
"quiet_hours_timezone": payload.timezone,
}
@app.patch("/api/v1/push-subscription/deadlines/snooze")
async def snooze_deadline_reminder(request: Request):
device_id = await dashboard_auth.session_management_id(
@@ -4590,28 +4833,43 @@ async def _load_context_for_user(user_data: dict) -> dict:
async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
requested = set(sections or LIVE_SNAPSHOT_SECTIONS)
results: dict[str, object] = {}
user_data: dict | None = None
user_task: asyncio.Task | None = None
if requested & {"context", "events"}:
user_task = asyncio.create_task(current_user())
async def load_user_section(section: str) -> object:
assert user_task is not None
user_data = await asyncio.shield(user_task)
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
if section == "context":
return await _load_context_for_user(user_data)
return await activity_events(user_data)
async def load_before_deadline(load: Awaitable[Any]) -> object:
try:
user_data = await current_user()
if not isinstance(user_data, dict) or not user_data.get("login"):
raise ContextPayloadError("Gitea current-user response was invalid")
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
return await load
except Exception as exc:
for section in requested & {"context", "events"}:
results[section] = exc
return exc
loads: dict[str, Awaitable[Any]] = {}
if "context" in requested and "context" not in results:
assert user_data is not None
loads["context"] = _load_context_for_user(user_data)
if "events" in requested and "events" not in results:
assert user_data is not None
loads["events"] = activity_events(user_data)
if "context" in requested:
loads["context"] = load_user_section("context")
if "events" in requested:
loads["events"] = load_user_section("events")
if "notifications" in requested:
loads["notifications"] = notifications()
if loads:
loaded = await asyncio.gather(*loads.values(), return_exceptions=True)
results.update(zip(loads, loaded))
try:
if loads:
loaded = await asyncio.gather(
*(load_before_deadline(load) for load in loads.values())
)
results.update(zip(loads, loaded))
finally:
if user_task is not None and not user_task.done():
user_task.cancel()
await asyncio.gather(user_task, return_exceptions=True)
context_result = results.get("context")
events_result = results.get("events")
@@ -4650,7 +4908,9 @@ async def _build_live_snapshot(sections: set[str] | None = None) -> dict:
async def _build_live_snapshot_before_deadline(sections: set[str]) -> dict:
async with asyncio.timeout(CONTEXT_TIMEOUT_SECONDS):
async with asyncio.timeout(
CONTEXT_TIMEOUT_SECONDS + LIVE_SNAPSHOT_DEADLINE_GRACE_SECONDS
):
return await _build_live_snapshot(sections)
@@ -6441,6 +6701,144 @@ async def delete_notification_comment(
)
async def _read_conversation_reactions(
repository: str, number: int, comment_id: int
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.comment_reactions(repository, number, comment_id),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
except Exception:
return JSONResponse(
{"error": "Reactions could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
async def _set_conversation_reaction(
repository: str,
number: int,
comment_id: int,
content: str,
active: bool,
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.set_comment_reaction(
repository, number, comment_id, content, active
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
raise HTTPException(status_code=403, detail="Comment is not part of this conversation") from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception:
return JSONResponse(
{"error": "The reaction could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions")
async def assigned_issue_comment_reactions(
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _read_conversation_reactions(repository, number, comment_id)
@app.put("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}/reactions/{content}")
async def set_assigned_issue_comment_reaction(
state: CommentReactionState,
owner: str,
repo: str,
content: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _set_conversation_reaction(
repository, number, comment_id, content, state.active
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions")
async def assigned_pull_comment_reactions(
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _read_conversation_reactions(repository, number, comment_id)
@app.put("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}/reactions/{content}")
async def set_assigned_pull_comment_reaction(
state: CommentReactionState,
owner: str,
repo: str,
content: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not _has_pull_workspace_access(
await _pull_workspace_capabilities(repository, number)
):
raise HTTPException(status_code=404, detail="Pull request not found")
return await _set_conversation_reaction(
repository, number, comment_id, content, state.active
)
@app.get("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions")
async def notification_comment_reactions(
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _read_conversation_reactions(repository, number, comment_id)
@app.put("/api/v1/notifications/{thread_id}/comments/{comment_id}/reactions/{content}")
async def set_notification_comment_reaction(
state: CommentReactionState,
content: str,
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _set_conversation_reaction(
repository, number, comment_id, content, state.active
)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
async def attach_to_assigned_issue(
request: Request,
@@ -7017,6 +7415,35 @@ async def commit_authored_pull_feedback_fix(
return JSONResponse(result)
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/feedback-batch")
async def commit_authored_pull_feedback_batch(
update: PullFeedbackBatchUpdate,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
try:
result = await asyncio.wait_for(
gitea_proxy.commit_authored_pull_feedback_batch(
f"{owner}/{repo}", number,
[file.model_dump() for file in update.files],
update.suggestion_count, update.message, update.expected_head_sha,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or a suggested file changed. No fixes were committed."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The suggestion batch could not be confirmed. Keep the batch and retry."},
status_code=503, headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/review-data")
async def assigned_pull_review_data(
owner: str, repo: str, number: int = PathParam(gt=0)
+42 -16
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,
@@ -241,6 +252,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 +261,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 +277,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 +320,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 +399,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,
},
+110 -11
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,7 @@ class FollowingNotificationDevice:
session_id: str
subscription: dict
delivered_fingerprint: str | None
catch_up: bool = False
class DisabledPushSubscriptionStore:
@@ -91,10 +95,13 @@ class DisabledPushSubscriptionStore:
def following_preferences(self, session_id: str) -> dict:
return {"enabled": False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
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 claim_unseen(self, thread_revisions, *, now: float | None = None) -> list[PushDelivery]:
return []
def acquire_dispatch_lease(self, *args, **kwargs) -> bool:
@@ -133,6 +140,9 @@ class DisabledPushSubscriptionStore:
def set_following_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
@@ -183,6 +193,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 +282,16 @@ class PushSubscriptionStore:
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 = {
@@ -612,20 +642,64 @@ class PushSubscriptionStore:
).fetchone()
return {"enabled": bool(row[0]) if row else False}
def following_notification_devices(self) -> list[FollowingNotificationDevice]:
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 +708,14 @@ 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 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 +726,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 +794,7 @@ class PushSubscriptionStore:
self._open_subscription(session_id, encoded),
unseen,
tuple(digest_revisions),
catch_up,
)
)
return deliveries
@@ -770,3 +865,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,),
)
@@ -0,0 +1,61 @@
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()
@@ -0,0 +1,95 @@
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, 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"}
list_requests: list[str] = []
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": [],
"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:
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 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.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")
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")
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)
@@ -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
@@ -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
@@ -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)
@@ -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()
@@ -169,7 +169,7 @@ 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(
"requestfinished",
@@ -186,6 +186,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
page.goto(origin + "/", wait_until="networkidle")
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,6 +194,8 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
), launch_transfer_events
expect(page.locator("#my-work-status")).to_contain_text("2")
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.evaluate(
"""
@@ -236,6 +239,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
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")
@@ -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()
@@ -0,0 +1,99 @@
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, 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")
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)
@@ -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()
@@ -416,6 +416,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()
@@ -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()
+34 -3
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
@@ -54,7 +85,7 @@ def test_release_promotion_waits_for_every_packaged_mobile_journey():
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
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v139" in worker
assert "stackchain-dashboard-shell-v145" in worker
+308
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."}
+119
View File
@@ -117,6 +117,125 @@ const poller = createContextPoller({{
}
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
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))});
@@ -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],
}
+62
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))});
+1 -1
View File
@@ -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-v139" in service_worker
assert "stackchain-dashboard-shell-v145" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
+22 -4
View File
@@ -50,7 +50,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,7 +95,6 @@ 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
)
@@ -127,14 +126,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
+280
View File
@@ -0,0 +1,280 @@
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_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().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().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"]
+142
View File
@@ -0,0 +1,142 @@
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_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")
+491
View File
@@ -0,0 +1,491 @@
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"
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_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 "bot" in output["html"]
assert "intake" in output["html"]
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_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-v145" 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
+24
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
+1 -1
View File
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/later-sync.js'" in source
+81
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():
+2 -2
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-v139" in worker
assert "stackchain-dashboard-shell-v145" in worker
+1 -1
View File
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v139" in worker
assert "stackchain-dashboard-shell-v145" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
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-v139" in worker
assert "stackchain-dashboard-shell-v145" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
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-v139" in worker
assert "stackchain-dashboard-shell-v145" in worker
assert "BASE + 'static/mobile-insights.js'" in worker
+7 -1
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
+110
View File
@@ -0,0 +1,110 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTROLLER = ROOT / "frontend" / "mobile-search-modal.js"
def run_node(script: str) -> dict:
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_search_preview_journey_keeps_background_inert_and_restores_prior_state():
script = f"""
const createModal = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = (name) => ({{
name, inert:false, isConnected:true, hidden:false, disabled:false, focuses:0,
focus() {{ this.focuses += 1; }},
matches() {{ return true; }},
}});
const header=element('header'), main=element('main'), dock=element('dock'); dock.inert=true;
const palette=element('palette'), preview=element('preview');
const input=element('input'), close=element('close'), opener=element('opener');
palette.querySelectorAll=()=>[input]; preview.querySelectorAll=()=>[close];
const controller=createModal({{
document:{{addEventListener(name,fn){{listeners[name]=fn;}}}},
backgrounds:[header,main,dock],
}});
controller.activate(palette, {{opener, initialFocus:input}});
const opened=[header.inert,main.inert,dock.inert,input.focuses];
controller.transition(preview, {{initialFocus:close}});
const previewed=[header.inert,main.inert,dock.inert,close.focuses,controller.current()===preview];
controller.transition(palette, {{initialFocus:input}});
const returned=[header.inert,main.inert,dock.inert,input.focuses];
controller.deactivate();
process.stdout.write(JSON.stringify({{opened,previewed,returned,restored:[header.inert,main.inert,dock.inert,opener.focuses]}}));
"""
assert run_node(script) == {
"opened": [True, True, True, 1],
"previewed": [True, True, True, 1, True],
"returned": [True, True, True, 2],
"restored": [False, False, True, 1],
}
def test_tab_and_shift_tab_wrap_dynamic_visible_controls_on_active_surface():
script = f"""
const createModal = require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=(name)=>({{name,inert:false,isConnected:true,hidden:false,disabled:false,focuses:0,
focus(){{this.focuses+=1;}},matches(selector){{return !this.hidden;}}}});
const surface=element('surface'), first=element('first'), middle=element('middle'), last=element('last');
const hidden=element('hidden'); hidden.hidden=true; const disabled=element('disabled'); disabled.disabled=true;
surface.querySelectorAll=()=>[first,hidden,middle,disabled,last];
const document={{activeElement:last,addEventListener(name,fn){{listeners[name]=fn;}}}};
const controller=createModal({{document,backgrounds:[]}});
controller.activate(surface,{{initialFocus:first}});
let prevented=0;
document.activeElement=last; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
document.activeElement=first; listeners.keydown({{key:'Tab',shiftKey:true,preventDefault(){{prevented+=1;}}}});
// A newly rendered enabled control becomes the last stop without remounting.
const dynamic=element('dynamic'); surface.querySelectorAll=()=>[first,middle,last,dynamic];
document.activeElement=dynamic; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{first:first.focuses,last:last.focuses,prevented}}));
"""
assert run_node(script) == {"first": 3, "last": 1, "prevented": 3}
def test_disconnected_opener_is_not_focused_on_final_close():
script = f"""
const createModal=require({json.dumps(str(CONTROLLER))});
const surface={{querySelectorAll(){{return[];}}}};
const opener={{isConnected:true,focuses:0,focus(){{this.focuses+=1;}}}};
const document={{addEventListener(){{}}}};
const controller=createModal({{document,backgrounds:[]}});
controller.activate(surface,{{opener}}); opener.isConnected=false; controller.deactivate();
process.stdout.write(JSON.stringify({{focuses:opener.focuses}}));
"""
assert run_node(script) == {"focuses": 0}
def test_controls_inside_collapsed_sections_are_excluded_from_tab_order():
script = f"""
const createModal=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const visible={{disabled:false,hidden:false,isConnected:true,focuses:0,matches(){{return true;}},getClientRects(){{return [{{}}];}},focus(){{this.focuses+=1;}}}};
const collapsed={{...visible,getClientRects(){{return [];}}}};
const surface={{querySelectorAll(){{return [visible,collapsed];}}}};
const document={{activeElement:visible,addEventListener(name,fn){{listeners[name]=fn;}}}};
const controller=createModal({{document,backgrounds:[]}}); controller.activate(surface);
let prevented=0; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{prevented,focuses:visible.focuses}}));
"""
assert run_node(script) == {"prevented": 1, "focuses": 2}
def test_search_modal_is_packaged_with_accessible_semantics():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="cmd-palette" role="dialog" aria-modal="true"' in html
assert '<script src="static/mobile-search-modal.js"></script>' in html
assert '"static/mobile-search-modal.js"' in bundle
assert "createMobileSearchModal({" in dashboard
assert "searchModal.transition(qs('#search-preview')" in dashboard
+56 -1
View File
@@ -110,6 +110,61 @@ process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
assert output["handoffs"] == ["agenda"]
def test_prepare_today_reviews_human_gates_after_delivery_before_agenda():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{delivery:1, gate:2, agenda:1, today:4}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
output = run_node(script)
assert output == {
"briefing": {
"total": 4,
"next": "delivery",
"label": "Review Delivery",
"summary": "1 delivery needs action before Today · 3 other items · 4 planned",
"phases": [
{"name": "delivery", "label": "Delivery recovery", "count": 1},
{"name": "gate", "label": "Human Gates", "count": 2},
{"name": "agenda", "label": "Agenda", "count": 1},
],
},
"opened": ["delivery"],
}
def test_prepare_today_keeps_unavailable_human_gates_retryable_before_today():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
const opened = [];
const controller = createStartDay({{
getCounts: () => ({{gateUnavailable:true, today:2}}),
openQueue: name => opened.push(name),
}});
const briefing = controller.briefing();
controller.startNext();
process.stdout.write(JSON.stringify({{briefing, opened}}));
"""
assert run_node(script) == {
"briefing": {
"total": 0,
"next": "gate",
"label": "Retry Human Gates",
"summary": "Human Gates need retry before Today · 2 planned",
"phases": [{"name": "gate", "label": "Human Gates unavailable · retry", "count": 0}],
},
"opened": ["gate"],
}
def test_prepare_today_counts_each_work_identity_in_only_its_highest_priority_phase():
script = f"""
const createStartDay = require({json.dumps(str(START_DAY))});
@@ -414,7 +469,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
assert ".mobile-start-day-finish { min-height:44px;" in html
assert "max-width:100%; overflow-wrap:anywhere;" in html
assert "BASE + 'static/mobile-start-day.js'" in service_worker
assert "stackchain-dashboard-shell-v139" in service_worker
assert "stackchain-dashboard-shell-v145" in service_worker
@pytest.mark.anyio
+118 -9
View File
@@ -279,6 +279,27 @@ process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
}
@pytest.mark.anyio
async def test_mobile_queue_sheet_prioritizes_next_active_and_planning_without_duplicate_rows():
html = await dashboard()
assert 'aria-labelledby="mobile-queue-next-heading"' in html
assert 'id="mobile-queue-next-action"' in html
assert 'aria-labelledby="mobile-queue-active-heading"' in html
assert 'id="mobile-queue-active-list"' in html
assert 'aria-labelledby="mobile-queue-planning-heading"' in html
assert 'id="mobile-queue-planning-list"' in html
assert '<details class="mobile-queue-all"' in html
assert 'id="mobile-queue-all-list"' in html
assert "nextAction: qs('#mobile-queue-next-action')" in html
assert "activeList: qs('#mobile-queue-active-list')" in html
assert "mobileQueueLauncher.renderPresentation();" in html
assert "queueCounts.followingUnavailable = status === 'error';\n renderMobileQueuePresentation();" in html
assert "mobileQueueLauncher.continueWork()" in html
for name in ("today", "tomorrow", "week", "agenda", "delivery", "gate", "attention", "update", "following", "filed", "authored", "later", "draft", "find", "recaps"):
assert html.count(f'<button data-mobile-queue="{name}"') == 1
@pytest.mark.anyio
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
html = await dashboard()
@@ -469,7 +490,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const deadline = new FakeElement();
const rows = Object.fromEntries(['today','agenda','delivery','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const rows = Object.fromEntries(['today','agenda','delivery','gate','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
@@ -480,7 +501,7 @@ const dock = createDock({{
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, agenda:5, delivery:1, attention:1, update:5, filed:2, later:3, draft:4}});
dock.updateQueues({{today:2, agenda:5, delivery:1, gate:2, attention:1, update:5, filed:2, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.update.click();
@@ -525,9 +546,9 @@ process.stdout.write(JSON.stringify({{
"utilities": ["recaps:trigger"],
"badge": "0 active",
"populated": {
"badge": "7 active",
"badge": "8 active",
"badgeHidden": False,
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 7 active queues",
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Human Gates 2, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 8 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
@@ -536,13 +557,13 @@ process.stdout.write(JSON.stringify({{
"badge": "1 active",
"badgeHidden": False,
"deadlineHidden": True,
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Human Gates 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "delivery": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"counts": {"today": "0", "agenda": "0", "delivery": "0", "gate": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
"columnState": None,
@@ -599,6 +620,27 @@ process.stdout.write(JSON.stringify({{result, calls}}));
}
def test_mobile_queue_launcher_uses_existing_human_gate_review_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
openHumanGates: () => {{ calls.push('human-gates'); return 'opened-gates'; }},
selectFilter: name => calls.push('generic-filter:' + name),
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
announce: message => calls.push('announce:' + message),
}});
process.stdout.write(JSON.stringify({{result:launcher.open('gate'), calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "opened-gates",
"calls": ["human-gates"],
}
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
@@ -621,7 +663,7 @@ def test_mobile_work_prioritizes_and_revalidates_actionable_delivery_recovery():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{delivery:2, attention:3, today:1, update:4}};
let counts = {{delivery:2, gate:1, attention:3, today:1, update:4}};
const launcher = createLauncher({{
getCounts: () => counts,
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened-delivery'; }},
@@ -633,7 +675,7 @@ const launcher = createLauncher({{
}});
const delivery = launcher.recommend();
const opened = launcher.continueWork();
counts = {{delivery:0, attention:0, today:0, update:4}};
counts = {{delivery:0, gate:1, attention:0, today:0, update:4}};
const afterRecovery = launcher.recommend();
process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}));
"""
@@ -643,7 +685,7 @@ process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}))
assert json.loads(result.stdout) == {
"delivery": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
"opened": "opened-delivery",
"afterRecovery": {"name": "update", "count": 4, "label": "Resume Updates (4)"},
"afterRecovery": {"name": "gate", "count": 1, "label": "Review Human Gates (1)"},
"calls": ["delivery-recovery"],
}
@@ -710,6 +752,73 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
}
def test_mobile_queue_launcher_builds_truthful_next_up_and_active_sections():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const counts = {{delivery:1, gate:2, today:3, authored:4, update:0, attentionUnavailable:true, followingUnavailable:true}};
const launcher = createLauncher({{getCounts:() => counts}});
process.stdout.write(JSON.stringify(launcher.presentation()));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"nextUp": {"name": "delivery", "count": 1, "label": "Recover Delivery (1)"},
"active": [
{"name": "delivery", "count": 1},
{"name": "gate", "count": 2},
{"name": "today", "count": 3},
{"name": "authored", "count": 4},
{"name": "attention", "unavailable": True},
{"name": "following", "unavailable": True},
],
"planning": ["today", "tomorrow", "week"],
"all": [
"today", "tomorrow", "week", "agenda", "delivery", "gate",
"attention", "update", "following", "filed", "authored", "later",
"draft", "find", "recaps",
],
}
def test_mobile_queue_launcher_renders_one_set_of_rows_into_adaptive_groups():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
class Box {{
constructor(name) {{ this.name=name; this.children=[]; this.hidden=false; this.attributes={{}}; this.textContent=''; this.dataset={{}}; }}
append(row) {{ if (row.parent) row.parent.children=row.parent.children.filter(item => item !== row); this.children.push(row); row.parent=this; }}
setAttribute(name, value) {{ this.attributes[name]=value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
}}
const names=['today','tomorrow','week','agenda','delivery','gate','attention','update','following','filed','authored','later','draft','find','recaps'];
const rows=Object.fromEntries(names.map(name => [name,new Box(name)]));
const nextAction=new Box('next');
const activeList=new Box('active'); const planningList=new Box('planning'); const allList=new Box('all');
const activeSection=new Box('active-section');
const launcher=createLauncher({{
getCounts:()=>({{delivery:1,gate:2,today:3,attentionUnavailable:true}}), rows,
nextAction, activeList, planningList, allList, activeSection,
}});
launcher.renderPresentation();
process.stdout.write(JSON.stringify({{
next:[nextAction.textContent,nextAction.dataset.queue,nextAction.attributes['aria-label']],
active:activeList.children.map(row => [row.name,row.attributes['data-unavailable'] || null]),
planning:planningList.children.map(row => row.name),
all:allList.children.map(row => row.name), activeHidden:activeSection.hidden,
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"next": ["Recover Delivery (1)", "delivery", "Next up: Recover Delivery (1)"],
"active": [["delivery", None], ["gate", None], ["attention", "true"]],
"planning": ["today", "tomorrow", "week"],
"all": ["agenda", "update", "following", "filed", "authored", "later", "draft", "find", "recaps"],
"activeHidden": False,
}
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
+111
View File
@@ -0,0 +1,111 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "photo-draft-inbox.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
def run_node(script: str):
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(completed.stdout)
def test_photo_draft_inbox_combines_stores_without_duplicate_target_cards_and_discards_exact_bundle():
script = f"""
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
const removed=[];
const conversation={{
list:async()=>[
{{id:'conversation-issue',kind:'photo-reply',photo_store:'conversation',title:'o/r#7',updated_at:30,route:{{kind:'issue',repository:'o/r',number:7}}}},
{{id:'conversation-update',kind:'photo-reply',photo_store:'conversation',title:'Update #8',updated_at:20,route:{{kind:'update',notification_id:8}}}},
],
remove:async target=>removed.push(['conversation',target]),
}};
const search={{
list:async()=>[
{{id:'search-pull',kind:'photo-reply',photo_store:'search',title:'o/r#9',updated_at:40,route:{{kind:'search',target_kind:'pull',repository:'o/r',number:9}}}},
],
remove:async target=>removed.push(['search',target]),
}};
const inbox=createPhotoDraftInbox({{conversation,search}});
(async()=>{{
const drafts=await inbox.refresh([{{kind:'issue-comment',route:{{kind:'issue',repository:'o/r',number:7}}}}]);
const description=inbox.description({{kind:'photo-reply',photo_count:2}});
const searchTarget=inbox.searchTarget(drafts[0]);
await inbox.discard(drafts[0]);
process.stdout.write(JSON.stringify({{drafts,description,searchTarget,remaining:inbox.list(),removed}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"drafts": [
{
"id": "search-pull",
"kind": "photo-reply",
"photo_store": "search",
"title": "o/r#9",
"updated_at": 40,
"route": {"kind": "search", "target_kind": "pull", "repository": "o/r", "number": 9},
},
{
"id": "conversation-update",
"kind": "photo-reply",
"photo_store": "conversation",
"title": "Update #8",
"updated_at": 20,
"route": {"kind": "update", "notification_id": 8},
},
],
"description": "2 saved photos",
"searchTarget": {"kind": "pull", "repository": "o/r", "number": 9, "title": "o/r#9"},
"remaining": [
{
"id": "conversation-update",
"kind": "photo-reply",
"photo_store": "conversation",
"title": "Update #8",
"updated_at": 20,
"route": {"kind": "update", "notification_id": 8},
}
],
"removed": [["search", {"kind": "pull", "repository": "o/r", "number": 9}]],
}
def test_photo_draft_inbox_keeps_available_store_when_other_inventory_fails():
script = f"""
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
const inbox=createPhotoDraftInbox({{
conversation:{{list:async()=>{{throw new Error('IndexedDB unavailable')}}}},
search:{{list:async()=>[{{id:'safe',route:{{kind:'search',target_kind:'issue',repository:'o/r',number:2}},updated_at:1}}]}},
}});
(async()=>process.stdout.write(JSON.stringify(await inbox.refresh([]))))().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == [
{"id": "safe", "route": {"kind": "search", "target_kind": "issue", "repository": "o/r", "number": 2}, "updated_at": 1}
]
def test_mobile_my_work_discovers_reopens_and_discards_photo_only_reply_drafts():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
worker = WORKER.read_text()
bundle = BUNDLE.read_text()
assert '<script src="static/photo-draft-inbox.js"></script>' in index
assert "BASE + 'static/photo-draft-inbox.js'" in worker
assert '"static/photo-draft-inbox.js"' in bundle
assert "const photoDraftInbox = createPhotoDraftInbox({" in dashboard
assert "draftInbox.list().concat(photoDraftInbox.list(), unfiled)" in dashboard
assert "await photoDraftInbox.refresh(draftInbox.list())" in dashboard
assert "refreshMyWorkView({ reconcileSession:false, refreshFirstTask:false })" in dashboard
assert "void refreshPhotoDraftInbox();" in dashboard
assert "onChange:() => { void refreshPhotoDraftInbox(); }" in dashboard
assert "await searchReplyDraftStore.save(target, attachments);\n void refreshPhotoDraftInbox();" in dashboard
assert "photoDraftInbox.description(item)" in dashboard
assert "photoDraftInbox.searchTarget(item)" in dashboard
assert "await photoDraftInbox.discard(item)" in dashboard
+1 -1
View File
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+299
View File
@@ -0,0 +1,299 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "progressive-capture.js"
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
def run_capture(scenario: str) -> dict:
harness = f"""
const createProgressiveCapture = require({json.dumps(str(MODULE))});
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
return json.loads(completed.stdout)
def test_progressive_mobile_new_opens_and_saves_one_account_bound_draft():
result = run_capture(r"""
const listeners = new Map();
const button = {dataset:{mobileTask:'new'}, addEventListener(name, callback){listeners.set('new:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('new:'+name)===callback) listeners.delete('new:'+name);}, setAttribute(){}, removeAttribute(){}};
const save = {disabled:false, addEventListener(name, callback){listeners.set('save:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('save:'+name)===callback) listeners.delete('save:'+name);}};
const file = {disabled:false, addEventListener(){}, removeEventListener(){}};
const cancel = {addEventListener(){}, removeEventListener(){}};
const field = name => ({value:'', focused:0, focus(){this.focused++;},
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);},
});
const title = field('title');
const body = field('body');
const heading = {textContent:''};
const status = {textContent:''};
const classes = new Set();
const root = {classList:{add:name=>classes.add(name), remove:name=>classes.delete(name), contains:name=>classes.has(name)}};
const nodes = {
'#create-issue-sheet':root, '#create-issue-title':title, '#create-issue-body':body,
'#create-issue-heading':heading, '#save-unfiled-issue':save, '#file-new-issue':file,
'#cancel-new-issue':cancel, '#my-work-action-status':status,
};
const document = {
querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);},
};
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 flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42});
flow.start();
listeners.get('new:click')({currentTarget:button});
title.value='Production outage'; listeners.get('title:input')();
body.value='Investigate mobile reports'; listeners.get('body:input')();
await listeners.get('save:click')();
await listeners.get('save:click')();
const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1'));
console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()],checkpoint:values.get('stackchain.progressive-capture.v1')||null}));
""")
assert result == {
"open": False,
"focused": 1,
"heading": "Capture work",
"status": "Saved to Drafts. Choose a repository when you’re ready to file it.",
"items": [{
"id": "draft-1",
"ownerLogin": "timmy",
"title": "Production outage",
"body": "Investigate mobile reports",
"savedAt": 42,
}],
"listeners": ["new:click", "save:click", "title:input", "body:input"],
"checkpoint": None,
}
def test_failed_progressive_save_keeps_rendered_fields_and_checkpoint_for_retry():
result = run_capture(r"""
const listeners={};
const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){this.focused=true;}});
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
const title=control('title'); const body=control('body'); const status={textContent:''}; const classes=new Set();
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':status};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
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 flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:()=>({save(){throw new Error('Phone storage is full.');}})});
flow.start(); listeners['new:click']();
title.value='Keep title'; listeners['title:input'](); body.value='Keep note'; listeners['body:input']();
const saved=await listeners['save:click']();
console.log(JSON.stringify({saved,open:classes.has('open'),title:title.value,body:body.value,status:status.textContent,
checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1'))}));
""")
assert result == {
"saved": False,
"open": True,
"title": "Keep title",
"body": "Keep note",
"status": "Phone storage is full.",
"checkpoint": {
"version": 1,
"items": [{
"ownerLogin": "timmy",
"title": "Keep title",
"body": "Keep note",
}],
},
}
def test_progressive_capture_restores_latest_checkpoint_only_for_its_account():
result = run_capture(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),
};
function mount(login) {
const listeners = new Map();
const control = name => ({disabled:false,value:'',
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
removeEventListener(){},focus(){},
});
const button=control('new'); const save=control('save'); const file=control('file');
const cancel=control('cancel'); const title=control('title'); const body=control('body');
const status={textContent:''}; const classes=new Set();
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':status};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
const flow=createProgressiveCapture({document,storage,getLogin:()=>login,createCaptures:createUnfiledCaptures});
flow.start();
return {flow,listeners,title,body,status,button};
}
const first=mount('timmy'); first.listeners.get('new:click')();
first.title.value='Production outage'; first.listeners.get('title:input')?.();
first.body.value='Latest phone note'; first.listeners.get('body:input')?.();
const restored=mount('timmy'); restored.listeners.get('new:click')();
const other=mount('alexander'); other.listeners.get('new:click')();
other.title.value='Alexander note'; other.listeners.get('title:input')?.();
const timmyAgain=mount('timmy'); timmyAgain.listeners.get('new:click')();
console.log(JSON.stringify({
restored:{title:restored.title.value,body:restored.body.value,status:restored.status.textContent},
other:{title:other.title.value,body:other.body.value,status:other.status.textContent},
timmyAgain:{title:timmyAgain.title.value,body:timmyAgain.body.value},
checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1') || 'null'),
}));
""")
assert result == {
"restored": {
"title": "Production outage",
"body": "Latest phone note",
"status": "Unfinished capture restored from this phone.",
},
"other": {"title": "Alexander note", "body": "", "status": ""},
"timmyAgain": {"title": "Production outage", "body": "Latest phone note"},
"checkpoint": {
"version": 1,
"items": [
{"ownerLogin": "timmy", "title": "Production outage", "body": "Latest phone note"},
{"ownerLogin": "alexander", "title": "Alexander note", "body": ""},
],
},
}
def test_hydration_handoff_clears_checkpoint_only_after_explicit_completion():
result = run_capture(r"""
const listeners={};
const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){}});
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
const title=control('title'); const body=control('body'); const root={classList:{add(){},remove(){},contains:()=>true}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':{textContent:''}};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
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 flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:createUnfiledCaptures});
flow.start(); title.value='Hand off'; listeners['title:input'](); body.value='Preserve me'; listeners['body:input']();
const handoff=flow.handoff();
const before=JSON.parse(values.get('stackchain.progressive-capture.v1'));
if (typeof handoff.complete === 'function') handoff.complete();
console.log(JSON.stringify({before,after:values.get('stackchain.progressive-capture.v1')||null}));
""")
assert result == {
"before": {
"version": 1,
"items": [{
"ownerLogin": "timmy",
"title": "Hand off",
"body": "Preserve me",
}],
},
"after": None,
}
def test_progressive_file_now_preserves_capture_on_failure_and_hands_it_off_once():
result = run_capture(r"""
const listeners = new Map();
const control = name => ({disabled:false,
addEventListener(event, callback){listeners.set(name+':'+event, callback);},
removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);},
focus(){this.focused=(this.focused||0)+1;},
});
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
const title=control('title'); title.value='Keep this'; const body={value:'Do not lose this note'};
const status={textContent:''}; const heading={textContent:''}; const classes=new Set();
const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':heading,'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':status};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null)};
let requests=0;
const flow=createProgressiveCapture({
document,storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',createCaptures:createUnfiledCaptures,
requestFullWorkspace:async()=>{requests++;throw new Error('offline');},
});
flow.start(); listeners.get('new:click')({currentTarget:button});
const filed=await listeners.get('file:click')();
const before=Array.from(listeners.keys()).sort();
const handoff=flow.handoff(); const second=flow.handoff();
console.log(JSON.stringify({filed,requests,open:classes.has('open'),status:status.textContent,
fileDisabled:file.disabled,focused:title.focused,title:title.value,body:body.value,before,
after:Array.from(listeners.keys()),handoff,second}));
""")
assert result == {
"filed": False,
"requests": 1,
"open": True,
"status": "Filing tools unavailable. Your capture is still editable; retry when connected.",
"fileDisabled": False,
"focused": 2,
"title": "Keep this",
"body": "Do not lose this note",
"before": ["cancel:click", "file:click", "new:click", "save:click", "title:input"],
"after": [],
"handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"},
"second": None,
}
def test_hydrated_dashboard_adopts_open_progressive_capture_without_losing_fields():
source = DASHBOARD.read_text()
assert "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source
assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source
assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source
assert "progressiveCaptureHandoff.complete?.();" in source
assert "await openCreateIssueSheet(false);" in source
def test_progressive_capture_is_mobile_only_and_hides_unhydrated_filing_controls():
source = MODULE.read_text()
css = CSS.read_text()
assert "window.matchMedia?.('(max-width: 600px)').matches === true" in source
assert "root?.classList.add('progressive-capture')" in source
assert "root?.classList.remove('progressive-capture')" in source
assert ".create-issue-sheet.progressive-capture .mobile-create-issue-nav" in css
assert ".create-issue-sheet.progressive-capture .create-issue-attachment" in css
assert ".create-issue-sheet.progressive-capture .create-issue-filing" in css
assert ".create-issue-sheet.progressive-capture #switch-to-create-pull" in css
def test_successful_filing_handoff_reenables_the_hydrated_file_control():
result = run_capture(r"""
const listeners={};
const control=name=>({disabled:false,addEventListener(event,callback){listeners[name+event]=callback;},removeEventListener(){},focus(){}});
const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel');
const title={value:'Ready to file',focus(){}}; const body={value:'Context'}; const status={textContent:''};
const classes=new Set(); const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}};
const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body,
'#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file,
'#cancel-new-issue':cancel,'#my-work-action-status':status};
const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]};
const flow=createProgressiveCapture({document,storage:{getItem:()=>null,setItem(){}},getLogin:()=>'timmy',
createCaptures:createUnfiledCaptures,requestFullWorkspace:async()=>true});
flow.start(); listeners.newclick({currentTarget:button});
const filed=await listeners.fileclick(); const before=file.disabled; const handoff=flow.handoff();
console.log(JSON.stringify({filed,before,after:file.disabled,handoff}));
""")
assert result == {
"filed": True,
"before": True,
"after": False,
"handoff": {"open": True, "title": "Ready to file", "body": "Context"},
}
+184
View File
@@ -0,0 +1,184 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "progressive-mobile-dock.js"
INDEX = ROOT / "frontend" / "index.html"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
CSS = ROOT / "frontend" / "dashboard.css"
MY_WORK = ROOT / "frontend" / "my-work.js"
PROGRESSIVE_MY_WORK = ROOT / "frontend" / "progressive-my-work.js"
def test_progressive_mobile_dock_opens_work_and_truthful_queues_before_hydration():
harness = f"""
const createDock=require({json.dumps(str(MODULE))});
function element(dataset={{}}) {{
return {{dataset,attrs:{{}},listeners:{{}},open:false,textContent:'',focusCount:0,
addEventListener(name,cb){{this.listeners[name]=cb;}},
removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}},
setAttribute(name,value){{this.attrs[name]=String(value);}},
removeAttribute(name){{delete this.attrs[name];}},
showModal(){{this.open=true;}},close(){{this.open=false;}},focus(){{this.focusCount+=1;}},
}};
}}
const work=element({{mobileTask:'work'}}); const queues=element({{mobileTask:'queues'}});
const sheet=element(); const close=element(); const next=element();
const rows=Object.fromEntries(['today','agenda','delivery','gate','attention','update','filed','later','draft','authored']
.map(name=>[name,element({{mobileQueue:name}})]));
const counts=Object.fromEntries(Object.keys(rows).map(name=>[name,element({{mobileQueueCount:name}})]));
const smalls=Object.fromEntries(Object.keys(rows).map(name=>[name,{{textContent:'source label'}}]));
Object.keys(rows).forEach(name=>rows[name].querySelector=selector=>selector==='small'?smalls[name]:null);
const document={{
querySelector(selector){{return {{
'#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next,
'[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues,
}}[selector]||null;}},
querySelectorAll(selector){{
if(selector==='[data-mobile-queue]')return Object.values(rows);
if(selector==='[data-mobile-queue-count]')return Object.values(counts);
return [];
}},
}};
const calls=[];
const myWork={{
counts:()=>({{all:3,filed:1,authored:1,attention:1,update:0}}),
openFirst:()=>calls.push(['openFirst']),
selectQueue:(name,options)=>calls.push(['selectQueue',name,options]),
}};
const dock=createDock({{document,myWork}}); dock.start();
queues.listeners.click({{currentTarget:queues}});
const opened=sheet.open; const initialHandoff=dock.handoff();
rows.filed.listeners.click();
work.listeners.click({{currentTarget:work}});
const known={{filed:counts.filed.textContent,attention:counts.attention.textContent}};
const loading={{
deferred:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent,gate:smalls.gate.textContent,later:smalls.later.textContent}},
unavailable:{{today:rows.today.attrs['data-unavailable'],delivery:rows.delivery.attrs['data-unavailable']}},
}};
dock.stop();
console.log(JSON.stringify({{
opened,initialHandoff,calls,loading,known,
restored:{{today:smalls.today.textContent,delivery:smalls.delivery.textContent}},
cleaned:!('data-unavailable' in rows.today.attrs) && !('data-progressive-loading' in rows.today.attrs),
listeners:{{work:Object.keys(work.listeners),queues:Object.keys(queues.listeners),filed:Object.keys(rows.filed.listeners)}},
}}));
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": True,
"initialHandoff": {"queueSheetOpen": True, "lastTask": "queues"},
"calls": [
["selectQueue", "filed", {"openFirst": True}],
["openFirst"],
],
"known": {"filed": "1", "attention": "1"},
"loading": {
"deferred": {
"today": "Still loading",
"delivery": "Still loading",
"gate": "Still loading",
"later": "Still loading",
},
"unavailable": {"today": "true", "delivery": "true"},
},
"restored": {"today": "source label", "delivery": "source label"},
"cleaned": True,
"listeners": {"work": [], "queues": [], "filed": []},
}
def test_progressive_mobile_dock_refreshes_counts_from_live_work_and_unsubscribes_on_handoff():
harness = f"""
const createDock=require({json.dumps(str(MODULE))});
function element(dataset={{}}) {{return {{dataset,textContent:'',listeners:{{}},attrs:{{}},open:false,
addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(name){{delete this.listeners[name];}},
setAttribute(name,value){{this.attrs[name]=String(value);}},removeAttribute(name){{delete this.attrs[name];}},
querySelector:()=>({{textContent:''}}),showModal(){{this.open=true;}},close(){{this.open=false;}},
}};}}
const work=element({{mobileTask:'work'}}),queues=element({{mobileTask:'queues'}}),sheet=element(),close=element(),next=element();
const filed=element({{mobileQueue:'filed'}}),filedCount=element({{mobileQueueCount:'filed'}});
const document={{querySelector:s=>({{
'[data-mobile-task="work"]':work,'[data-mobile-task="queues"]':queues,
'#mobile-queue-sheet':sheet,'#close-mobile-queues':close,'#mobile-queue-next-action':next,
}}[s]||null),querySelectorAll:s=>s==='[data-mobile-queue]'?[filed]:s==='[data-mobile-queue-count]'?[filedCount]:[]}};
let counts={{all:0,filed:0}}, subscriber=null, unsubscribed=false;
const myWork={{counts:()=>counts,subscribe(cb){{subscriber=cb;return()=>{{unsubscribed=true;subscriber=null;}};}},openFirst(){{}},selectQueue(){{}}}};
const dock=createDock({{document,myWork}}); dock.start();
const before=filedCount.textContent; counts={{all:2,filed:2}}; subscriber(); const after=filedCount.textContent;
dock.stop();
console.log(JSON.stringify({{before,after,unsubscribed,subscriber:subscriber===null}}));
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": "0", "after": "2", "unsubscribed": True, "subscriber": True,
}
def test_progressive_mobile_dock_is_shipped_before_optional_workspace_hydration():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/progressive-mobile-dock.js"></script>' in index
assert index.index('static/progressive-my-work.js') < index.index('static/progressive-mobile-dock.js')
assert index.index('static/progressive-mobile-dock.js') < index.index('static/dashboard.js')
assert "window.stackchainProgressiveMobileDock?.handoff?.()" in dashboard
assert "window.stackchainProgressiveMobileDock?.stop?.()" in dashboard
assert "mobileTaskDock.select(progressiveMobileDockHandoff.lastTask)" in dashboard
assert "progressiveMobileDockHandoff?.queueSheetOpen && !qs('#mobile-queue-sheet').open" in dashboard
assert '[data-progressive-loading="true"]::after' in css
assert "content:'Still loading · available after workspace starts'" in css
def test_progressive_my_work_exposes_counts_and_opens_the_first_selected_queue_item():
harness = f"""
const fs=require('fs'); const vm=require('vm');
function element(extra={{}}) {{return Object.assign({{
innerHTML:'',textContent:'',hidden:true,dataset:{{}},listeners:{{}},focusCount:0,
addEventListener(name,cb){{this.listeners[name]=cb;}},removeEventListener(){{}},
setAttribute(){{}},removeAttribute(){{}},focus(){{this.focusCount+=1;}},
}},extra);}}
const list=element(); const status=element(); const detail=element();
const title=element(); const meta=element(); const reason=element(); const close=element(); const link=element();
const elements={{'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':detail,
'#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta,
'#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close,
'#open-progressive-work-gitea':link}};
const buttons=['all','filed','authored','attention'].map(name=>element({{dataset:{{workFilter:name}}}}));
const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>buttons}};
const lifecycleTarget=element();
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(PROGRESSIVE_MY_WORK))},'utf8'),context);
const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{
user:{{login:'timmy'}},
issues:[{{number:7,title:'Filed issue',repository:'stackchain/dashboard',work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}],
pull_requests:[{{number:8,title:'Authored PR',repository:'stackchain/dashboard',work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}],
}})}});
(async()=>{{
let updates=0; const unsubscribe=flow.subscribe(()=>{{updates+=1;}});
await flow.start(); const counts=flow.counts();
const selected=flow.selectQueue('filed',{{openFirst:true}}); const handoff=flow.handoff();
unsubscribe(); flow.selectQueue('authored');
console.log(JSON.stringify({{counts,updates,selected,detailHidden:detail.hidden,title:title.textContent,handoff}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state["counts"] == {"all": 2, "filed": 1, "authored": 1, "attention": 0, "update": 0, "review": 0}
assert state["updates"] == 2
assert state["selected"] == "opened"
assert state["detailHidden"] is False
assert state["title"] == "Filed issue"
assert state["handoff"]["selectedFilter"] == "filed"
assert state["handoff"]["openWork"]["key"] == "stackchain/dashboard#7"
+606
View File
@@ -0,0 +1,606 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
LIVE_SNAPSHOT = Path(__file__).parents[1] / "frontend" / "progressive-live-snapshot.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
def test_progressive_live_snapshot_coalesces_concurrent_cold_launch_consumers():
harness = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
let calls=0, resolveRequest;
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:()=>{{
calls += 1;
return new Promise(resolve=>{{resolveRequest=resolve;}});
}}}});
(async()=>{{
const myWork=broker.acquire();
const humanGates=broker.acquire();
await Promise.resolve();
const callsWhilePending=calls;
const snapshot={{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}};
resolveRequest(snapshot);
const [first,second]=await Promise.all([myWork,humanGates]);
const cached=await broker.acquire();
process.stdout.write(JSON.stringify({{
callsWhilePending,calls,same:first===second && second===cached,
identity:broker.identity(),snapshot:broker.snapshot(),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"callsWhilePending": 1,
"calls": 1,
"same": True,
"identity": {"login": "timmy", "accountKey": "7:timmy"},
"snapshot": {
"context": {"user": {"id": 7, "login": "timmy"}},
"events": [],
"notifications": [],
},
}
def test_progressive_live_snapshot_retries_identityless_success_for_identity_consumers():
harness = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
let calls=0;
const responses=[
{{context:null,events:[],notifications:[{{id:1}}]}},
{{context:{{user:{{id:7,login:'timmy'}}}},events:[],notifications:[]}},
];
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{
calls += 1;
return responses.shift();
}}}});
(async()=>{{
const partial=await broker.acquire();
const first=await Promise.allSettled([
broker.acquire({{requireIdentity:true}}),
broker.acquire({{requireIdentity:true}}),
]);
const retries=[
broker.acquire({{requireIdentity:true}}),
broker.acquire({{requireIdentity:true}}),
];
const recovered=await Promise.all(retries);
process.stdout.write(JSON.stringify({{
calls,
partialNotifications:partial.notifications.length,
first:first.map(result=>result.status),
same:recovered[0]===recovered[1],
identity:broker.identity(),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 2,
"partialNotifications": 1,
"first": ["rejected", "rejected"],
"same": True,
"identity": {"login": "timmy", "accountKey": "7:timmy"},
}
def test_progressive_live_snapshot_retries_after_a_failed_shared_flight():
harness = f"""
const createProgressiveLiveSnapshot=require({json.dumps(str(LIVE_SNAPSHOT))});
let calls=0;
const snapshot={{context:{{user:{{login:'timmy'}}}},events:[],notifications:[]}};
const broker=createProgressiveLiveSnapshot({{fetchSnapshot:async()=>{{
calls += 1;
if(calls===1) throw new Error('temporary outage');
return snapshot;
}}}});
(async()=>{{
const first=await Promise.allSettled([broker.acquire(),broker.acquire()]);
const recovered=await broker.acquire();
process.stdout.write(JSON.stringify({{
calls,first:first.map(result=>result.status),recovered:recovered===snapshot,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"calls": 2,
"first": ["rejected", "rejected"],
"recovered": True,
}
def test_progressive_my_work_reports_reconnecting_for_degraded_context_instead_of_inbox_zero():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const list={{innerHTML:''}}; const status={{textContent:''}};
const document={{
hidden:false,
querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,
querySelectorAll:()=>[],
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
context:null,events:[],notifications:[{{id:1}}],
freshness:{{retry_in_seconds:5,sections:{{
context:{{degraded:true,retry_in_seconds:5}},
events:{{degraded:false,age_seconds:1}},notifications:{{degraded:false,age_seconds:1}},
}}}},
}})}});
(async()=>{{
const started=await flow.start();
process.stdout.write(JSON.stringify({{started,status:status.textContent,html:list.innerHTML,counts:flow.counts()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"started": False,
"status": "Assigned work is reconnecting…",
"html": '<div class="muted">Assigned work is reconnecting…</div>',
"counts": {"all": 0, "filed": 0, "authored": 0, "attention": 0,
"update": 0, "review": 0},
}
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const buttons=[
{{dataset:{{workFilter:'all'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
{{dataset:{{workFilter:'issue'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
];
const list={{innerHTML:''}}; const status={{textContent:''}};
const document={{querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,querySelectorAll:()=>buttons}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const createProgressiveMyWork=context.module.exports;
const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{
user:{{login:'timmy'}},issues:[{{number:7,title:'Fix mobile queue',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
}})}});
(async()=>{{
await flow.start(); const rendered=list.innerHTML;
buttons[1].cb();
console.log(JSON.stringify({{status:status.textContent,rendered,pressed:buttons.map(b=>b.attrs['aria-pressed'])}}));
}})().catch(e=>{{console.error(e);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert state["status"] == "1 assigned work item ready."
assert "Fix mobile queue" in state["rendered"]
assert 'data-progressive-work-index="0"' in state["rendered"]
assert "href=" not in state["rendered"]
assert state["pressed"] == ["false", "true"]
def test_progressive_my_work_keeps_supported_queues_truthful_while_planning_loads():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const names=['all','today','agenda','attention','filed','authored','issue','pull','review','update','later','draft'];
const counts=Object.fromEntries(names.map(name=>[name,{{textContent:'0'}}]));
const buttons=names.map(name=>({{
dataset:{{workFilter:name}},attrs:{{}},
addEventListener(_name,cb){{this.cb=cb;}},
removeEventListener(){{}},
setAttribute(key,value){{this.attrs[key]=value;}},
}}));
const list={{innerHTML:''}}; const status={{textContent:''}};
const document={{
querySelector(selector){{
if(selector==='#my-work-list')return list;
if(selector==='#my-work-status')return status;
const match=selector.match(/^\\[data-work-count="(.+)"\\]$/);
return match?counts[match[1]]:null;
}},
querySelectorAll:()=>buttons,
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
user:{{login:'timmy'}},
issues:[{{number:7,title:'Filed from phone',repository:'stackchain/dashboard',assignees:[],work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}],
pull_requests:[{{number:8,title:'Authored release',repository:'stackchain/dashboard',assignees:[],work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}],
}})}});
(async()=>{{
await flow.start();
buttons[names.indexOf('filed')].cb(); const filed=list.innerHTML;
buttons[names.indexOf('authored')].cb(); const authored=list.innerHTML;
buttons[names.indexOf('today')].cb(); const today=list.innerHTML;
console.log(JSON.stringify({{filed,authored,today,counts:Object.fromEntries(names.map(name=>[name,counts[name].textContent]))}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert "Filed from phone" in state["filed"]
assert "Authored release" in state["authored"]
assert "Today is still loading" in state["today"]
assert "No work" not in state["today"]
assert state["counts"] == {
"all": "2",
"today": "0",
"agenda": "0",
"attention": "0",
"filed": "1",
"authored": "1",
"issue": "1",
"pull": "1",
"review": "0",
"update": "0",
"later": "0",
"draft": "0",
}
def test_progressive_queue_selection_is_handed_to_the_hydrated_workspace():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const buttons=['all','filed'].map(name=>({{
dataset:{{workFilter:name}},attrs:{{}},removed:false,
addEventListener(_name,cb){{this.cb=cb;}},
removeEventListener(_name,cb){{if(cb===this.cb)this.removed=true;}},
setAttribute(key,value){{this.attrs[key]=value;}},
}}));
const document={{
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
querySelectorAll:()=>buttons,
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{user:{{login:'timmy'}},issues:[],pull_requests:[]}})}});
(async()=>{{
await flow.start(); buttons[1].cb();
const before=flow.handoff(); flow.stop(); const after=flow.handoff();
console.log(JSON.stringify({{before,after,removed:buttons.map(button=>button.removed)}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert state == {
"before": {"selectedFilter": "filed"},
"after": {"selectedFilter": "filed"},
"removed": [True, True],
}
dashboard = DASHBOARD.read_text()
assert "const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();" in dashboard
assert "progressiveWorkHandoff?.selectedFilter" in dashboard
def test_progressive_live_snapshot_is_handed_to_the_workspace_exactly_once():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const document={{
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
querySelectorAll:()=>[],
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const snapshot={{
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},
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:2}},notifications:{{degraded:false,age_seconds:2}},
}}}},
}};
const flow=context.module.exports({{document,fetchSnapshot:async()=>snapshot}});
(async()=>{{
await flow.start();
const first=flow.handoff(); const second=flow.handoff();
console.log(JSON.stringify({{
firstSnapshot:first.liveSnapshot,
secondHasSnapshot:Object.prototype.hasOwnProperty.call(second,'liveSnapshot'),
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert state == {"firstSnapshot": {
"context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []},
"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": 2},
"notifications": {"degraded": False, "age_seconds": 2},
},
},
}, "secondHasSnapshot": False}
def test_progressive_inflight_snapshot_survives_workspace_handoff_and_stop():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const document={{
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
querySelectorAll:()=>[],
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
let resolveSnapshot;
const snapshot={{context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[]}};
const flow=context.module.exports({{document,fetchSnapshot:()=>new Promise(resolve=>{{resolveSnapshot=resolve;}})}});
(async()=>{{
const started=flow.start();
await Promise.resolve();
const handoff=flow.handoff();
flow.stop();
resolveSnapshot(snapshot);
const transferred=await handoff.liveSnapshotPromise;
await started;
console.log(JSON.stringify({{transferred}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"transferred": {
"context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []},
"events": [],
"notifications": [],
}}
def test_progressive_capture_can_read_only_the_confirmed_snapshot_login():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const document={{
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
querySelectorAll:()=>[],
}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}}, events:[], notifications:[],
}})}});
(async()=>{{
const before=flow.login(); await flow.start(); const after=flow.login(); flow.stop();
console.log(JSON.stringify({{before,after,stopped:flow.login()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"}
def test_progressive_my_work_retries_a_failed_snapshot_without_reloading():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const list={{innerHTML:''}}; const status={{textContent:''}};
const document={{
querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null,
querySelectorAll:()=>[],
}};
let timer=null; let attempts=0;
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context);
context.createContextPoller=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{
document,
fetchSnapshot:async()=>{{
attempts += 1;
if(attempts===1) throw new Error('temporary outage');
return {{context:{{user:{{login:'timmy'}},issues:[{{
number:9,title:'Recovered assignment',repository:'stackchain/dashboard',
assignees:['timmy'],url:'https://forge.example/issues/9',
}}],pull_requests:[]}},events:[],notifications:[]}};
}},
pollerOptions:{{
setTimer:callback=>{{timer=callback; return 1;}}, clearTimer:()=>{{timer=null;}},
setDeadlineTimer:()=>2, clearDeadlineTimer:()=>{{}}, intervalMs:5,
}},
}});
(async()=>{{
const first=await flow.start();
const reconnecting=status.textContent;
if(!timer) throw new Error('retry was not scheduled');
timer(); await Promise.resolve(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve));
console.log(JSON.stringify({{first,reconnecting,attempts,status:status.textContent,html:list.innerHTML}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"first": False,
"reconnecting": "Assigned work is reconnecting…",
"attempts": 2,
"status": "1 assigned work item ready.",
"html": '<article class="my-work-card progressive-my-work-card"><button class="my-work-card-main" type="button" data-progressive-work-index="0"><strong>Recovered assignment</strong><span class="small">stackchain/dashboard#9 · Assigned to you</span></button></article>',
}
def test_progressive_my_work_refreshes_with_revisions_and_coalesces_lifecycle_recovery():
harness = f"""
const fs=require('fs'); const vm=require('vm');
const list={{innerHTML:''}}; const status={{textContent:''}};
const document={{
hidden:false,
querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null,
querySelectorAll:()=>[],
}};
const handlers={{}}; const removed=[];
const lifecycleTarget={{
addEventListener:(name,callback)=>{{handlers[name]=callback;}},
removeEventListener:(name,callback)=>{{if(handlers[name]===callback){{removed.push(name);delete handlers[name];}}}},
}};
let timer=null; let calls=[]; let resolveRefresh;
const initial={{
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],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:2}},notifications:{{degraded:false,age_seconds:2}}}}}},
}};
const updated={{...initial,context:{{user:{{login:'timmy'}},issues:[{{number:10,title:'New assignment',repository:'stackchain/dashboard',assignees:['timmy']}}],pull_requests:[]}}}};
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context);
context.createContextPoller=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{
document,lifecycleTarget,
fetchSnapshot:(revisions,options)=>{{
calls.push({{revisions,hasSignal:Boolean(options.signal)}});
if(calls.length===1)return Promise.resolve(initial);
return new Promise(resolve=>{{resolveRefresh=()=>resolve(updated);}});
}},
pollerOptions:{{
setTimer:callback=>{{timer=callback;return 1;}},clearTimer:()=>{{timer=null;}},
setDeadlineTimer:()=>2,clearDeadlineTimer:()=>{{}},intervalMs:5,
}},
}});
(async()=>{{
await flow.start(); const freshnessTimer=timer; freshnessTimer();
handlers.online(); handlers.visibilitychange();
await Promise.resolve();
const callsWhilePending=calls.length;
resolveRefresh(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve));
const handoff=flow.handoff(); flow.stop();
console.log(JSON.stringify({{
callsWhilePending,calls,latestTitle:handoff.liveSnapshot.context.issues[0].title,
removed:removed.sort(),status:status.textContent,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert state["callsWhilePending"] == 2
assert state["calls"][0] == {"revisions": {}, "hasSignal": True}
assert state["calls"][1] == {
"revisions": {
"context": "0123456789abcdef.1",
"events": "fedcba9876543210.2",
"notifications": "0011223344556677.3",
},
"hasSignal": True,
}
assert state["latestTitle"] == "New assignment"
assert state["removed"] == ["keydown", "online", "visibilitychange"]
assert state["status"] == "1 assigned work item ready."
def test_progressive_card_opens_safe_in_app_detail_and_hands_it_off_once():
harness = f"""
const fs=require('fs'); const vm=require('vm');
function element(extra={{}}) {{
return Object.assign({{
hidden:false,textContent:'',attrs:{{}},listeners:{{}},focusCount:0,
addEventListener(name,cb){{this.listeners[name]=cb;}},
removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}},
setAttribute(name,value){{this.attrs[name]=String(value);}},
removeAttribute(name){{delete this.attrs[name];}},
focus(){{this.focusCount += 1;}},
}},extra);
}}
const list=element({{innerHTML:''}}); const status=element();
const sheet=element({{hidden:true}}); const title=element(); const meta=element(); const reason=element();
const close=element(); const gitea=element({{hidden:true,href:''}});
const elements={{
'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':sheet,
'#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta,
'#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close,
'#open-progressive-work-gitea':gitea,
}};
const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>[]}};
const lifecycleTarget=element();
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{
user:{{login:'timmy'}},issues:[{{number:7,title:'<img src=x onerror=alert(1)>',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
}})}});
(async()=>{{
await flow.start();
const trigger=element({{dataset:{{progressiveWorkIndex:'0'}},closest:()=>trigger}});
list.listeners.click({{target:trigger,preventDefault(){{}}}});
const opened={{sheetHidden:sheet.hidden,title:title.textContent,meta:meta.textContent,reason:reason.textContent,giteaHidden:gitea.hidden,giteaHref:gitea.href,html:list.innerHTML}};
lifecycleTarget.listeners.keydown({{key:'Escape',preventDefault(){{}}}});
list.listeners.click({{target:trigger,preventDefault(){{}}}});
const first=flow.handoff(); const second=flow.handoff();
console.log(JSON.stringify({{opened,closed:sheet.hidden,focusCount:trigger.focusCount,first,second}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
state = json.loads(result.stdout)
assert state["opened"] == {
"sheetHidden": False,
"title": "<img src=x onerror=alert(1)>",
"meta": "stackchain/dashboard#7 · Issue",
"reason": "Assigned to you",
"giteaHidden": False,
"giteaHref": "https://forge.example/issues/7",
"html": '<article class="my-work-card progressive-my-work-card"><button class="my-work-card-main" type="button" data-progressive-work-index="0"><strong>&lt;img src=x onerror=alert(1)&gt;</strong><span class="small">stackchain/dashboard#7 · Assigned to you</span></button></article>',
}
assert state["closed"] is False
assert state["focusCount"] == 1
assert state["first"]["openWork"] == {
"kind": "issue",
"key": "stackchain/dashboard#7",
"number": 7,
"repository": "stackchain/dashboard",
}
assert "openWork" not in state["second"]
def test_progressive_detail_is_mobile_safe_and_adopted_by_hydrated_workspace():
html = INDEX.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="progressive-work-detail" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="progressive-work-detail-title" hidden' in html
assert 'id="close-progressive-work-detail"' in html
assert 'id="open-progressive-work-gitea"' in html
assert ".progressive-work-detail[hidden] { display:none; }" in css
assert "max-height:100dvh" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-top)" in css
assert "env(safe-area-inset-bottom)" in css
assert ".progressive-work-detail-panel button, .progressive-work-detail-panel .button-link" in css
assert "min-height:44px" in css
assert "@media(max-width:320px)" in css
assert "progressiveWorkHandoff?.openWork" in dashboard
assert "openRoutedWork(progressiveItem, null)" in dashboard
assert "qs('#progressive-work-detail').hidden = true" in dashboard
+172
View File
@@ -108,6 +108,150 @@ async def test_gitea_commits_feedback_fix_and_verifies_advanced_pull_head():
]
@pytest.mark.anyio
async def test_gitea_commits_cross_file_suggestions_as_one_atomic_commit():
requests = []
pull = {
"number": 7, "state": "open", "merged": False,
"user": {"login": "alex"},
"head": {"sha": "abc1234", "ref": "alex/review-fix", "repo": {"full_name": "stackchain/api"}},
"base": {"repo": {"full_name": "stackchain/api"}},
}
originals = {"src/api.py": ("return empty\n", "blob1"), "tests/test_api.py": ("assert empty\n", "blob2")}
updated = {"src/api.py": ("return handled\n", "blob3"), "tests/test_api.py": ("assert handled\n", "blob4")}
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "alex"})
if request.url.path == "/api/v1/repos/stackchain/api/pulls/7":
return httpx.Response(200, json=pull)
prefix = "/api/v1/repos/stackchain/api/contents/"
if request.url.path.startswith(prefix) and request.method == "GET":
path = request.url.path.removeprefix(prefix)
content, sha = (updated if request.url.params["ref"] == "def5678" else originals)[path]
return httpx.Response(200, json=_content_payload(content, sha))
if request.url.path == "/api/v1/repos/stackchain/api/contents" and request.method == "POST":
body = json.loads(request.content)
assert body == {
"branch": "alex/review-fix",
"message": "fix: apply review suggestions",
"files": [
{"operation": "update", "path": "src/api.py", "sha": "blob1",
"content": base64.b64encode(b"return handled\n").decode()},
{"operation": "update", "path": "tests/test_api.py", "sha": "blob2",
"content": base64.b64encode(b"assert handled\n").decode()},
],
}
pull["head"]["sha"] = "def5678"
return httpx.Response(200, json={"commit": {"sha": "def5678"}})
raise AssertionError(f"unexpected request: {request.method} {request.url}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.commit_authored_pull_feedback_batch(
"stackchain/api", 7,
[
{"path": "src/api.py", "content": "return handled\n", "expected_blob_sha": "blob1"},
{"path": "tests/test_api.py", "content": "assert handled\n", "expected_blob_sha": "blob2"},
],
2, "fix: apply review suggestions", "abc1234",
)
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 7,
"paths": ["src/api.py", "tests/test_api.py"],
"previous_head_sha": "abc1234", "head_sha": "def5678",
"suggestion_count": 2, "message": "fix: apply review suggestions",
}
assert len([request for request in requests if request == ("POST", "/api/v1/repos/stackchain/api/contents")]) == 1
assert not [request for request in requests if request[0] == "PUT"]
@pytest.mark.anyio
async def test_feedback_batch_api_requires_at_least_two_suggestions(monkeypatch):
called = False
async def commit(*args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
json={"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 1,
"file": {"path": "src/api.py", "content": "one\n", "expected_blob_sha": "blob1"}},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_feedback_batch_api_accepts_unique_cross_file_manifest(monkeypatch):
calls = []
async def commit(*args):
calls.append(args)
return {
"repository": "stackchain/api", "number": 7,
"paths": ["src/api.py", "tests/test_api.py"],
"previous_head_sha": "abc1234", "head_sha": "def5678",
"suggestion_count": 2, "message": "fix: suggestions",
}
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
json={
"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 2,
"files": [
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
{"path": "tests/test_api.py", "content": "assert after\n", "expected_blob_sha": "blob2"},
],
},
)
assert response.status_code == 200
assert response.json()["paths"] == ["src/api.py", "tests/test_api.py"]
assert calls[0][2] == [
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
{"path": "tests/test_api.py", "content": "assert after\n", "expected_blob_sha": "blob2"},
]
@pytest.mark.anyio
async def test_feedback_batch_api_rejects_duplicate_file_paths(monkeypatch):
called = False
async def commit(*args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "commit_authored_pull_feedback_batch", commit, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/feedback-batch",
json={
"message": "fix: suggestions", "expected_head_sha": "abc1234", "suggestion_count": 2,
"files": [
{"path": "src/api.py", "content": "after\n", "expected_blob_sha": "blob1"},
{"path": "src/api.py", "content": "other\n", "expected_blob_sha": "blob2"},
],
},
)
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_feedback_file_api_loads_and_commits_an_authored_pull_fix(monkeypatch):
calls = []
@@ -875,6 +1019,34 @@ def test_review_comments_are_bounded_and_require_a_file_and_body():
assert all(item["path"] and item["body"] for item in normalized)
def test_review_comments_expose_one_bounded_line_suggestion_only():
comments = [
{
"id": 1,
"path": "src/api.py",
"new_position": 4,
"body": "Handle the empty state:\n```suggestion\nreturn empty_result()\n```",
},
{
"id": 2,
"path": "src/api.py",
"new_position": 7,
"body": "Choose one:\n```suggestion\nfirst()\n```\n```suggestion\nsecond()\n```",
},
{
"id": 3,
"path": "src/api.py",
"body": "No stable line:\n```suggestion\nunsafe()\n```",
},
]
normalized = gitea_proxy._normalize_review_comments(comments)
assert normalized[0]["suggestion"] == {"line": 4, "content": "return empty_result()"}
assert "suggestion" not in normalized[1]
assert "suggestion" not in normalized[2]
def test_review_feedback_preserves_stable_review_and_comment_identities():
pull = {"requested_reviewers": []}
reviews = [{
+40
View File
@@ -34,6 +34,10 @@ const followingControl = {
addEventListener:(_name, callback) => state.followingChange = callback,
};
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
const quietStatus = {set textContent(value) { state.quietText = value; }, get textContent() { return state.quietText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
const deadlineSnooze = {hidden:true};
@@ -48,6 +52,7 @@ const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
quietControl, quietStart, quietEnd, quietStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
@@ -114,6 +119,41 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
assert "followingStatus:qs('#push-following-status')" in dashboard
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
result = run_scenario("""
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};
state.current = existing;
await feature.init();
quietStart.value = '22:15';
quietEnd.value = '07:30';
await state.quietStartChange();
process.stdout.write(JSON.stringify({requests:state.requests, checked:quietControl.checked, start:quietStart.value, end:quietEnd.value, text:state.quietText}));
""")
assert result["checked"] is True
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/quiet-hours", "PUT"]
body = json.loads(result["requests"][-1][2])
assert body["enabled"] is True
assert body["start"] == "22:15"
assert body["end"] == "07:30"
assert isinstance(body["timezone"], str) and body["timezone"]
assert result["text"] == "Routine alerts paused from 22:15 to 07:30 local time."
def test_device_settings_render_and_wire_mobile_quiet_hours():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="push-quiet-hours" type="checkbox"' in index
assert 'id="push-quiet-start" type="time"' in index
assert 'id="push-quiet-end" type="time"' in index
assert 'id="push-quiet-status" role="status" aria-live="polite"' in index
module = MODULE.read_text()
assert "querySelector('#push-quiet-hours')" in module
assert "querySelector('#push-quiet-start')" in module
assert "querySelector('#push-quiet-end')" in module
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
result = run_scenario("""
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
+141
View File
@@ -3,6 +3,7 @@ import asyncio
import os
import sqlite3
import time
from datetime import datetime, timezone
from types import SimpleNamespace
import httpx
@@ -62,6 +63,67 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
assert store.following_preferences("session-b") == {"enabled": False}
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_quiet_hours(
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
)
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
assert store.quiet_hours("session-a") == {
"enabled": True, "start": "22:00", "end": "07:00", "timezone": "UTC"
}
assert store.claim_unseen({42: "r1"}, now=inside) == []
delivery = store.claim_unseen({42: "r1"}, now=after)
assert len(delivery) == 1
assert delivery[0].thread_revisions == ((42, "r1"),)
assert delivery[0].catch_up is True
store.mark_delivered("session-a", delivery[0].thread_revisions)
assert store.claim_unseen({42: "r1"}, now=after) == []
@pytest.mark.anyio
async def test_unread_dispatch_sends_one_catch_up_digest_after_quiet_hours(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_quiet_hours(
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
)
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
assert store.claim_unseen({41: "r1", 42: "r1"}, now=inside) == []
sent = []
async def unread():
return {"items": [
{"id": 41, "updated_at": "r1"},
{"id": 42, "updated_at": "r1"},
]}
async def send(_subscription, payload):
sent.append(json.loads(payload))
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send, now=after) == 1
assert sent == [{
"title": "2 updates while alerts were paused",
"body": "Open Updates to catch up in Stackchain.",
"route": "#/my-work/updates",
"tag": "stackchain-update-catch-up",
"update_count": 2,
"unread_count": 2,
}]
@pytest.mark.anyio
async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -109,6 +171,43 @@ async def test_following_dispatch_is_private_deduplicated_and_session_bound(tmp_
assert store.subscription_for_session("revoked") is None
@pytest.mark.anyio
async def test_following_changes_wait_for_quiet_hours_and_resume_as_catch_up(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_following_preferences("session-a", enabled=True)
store.set_quiet_hours(
"session-a", enabled=True, start="22:00", end="07:00", timezone="UTC"
)
inside = datetime(2026, 8, 25, 23, 0, tzinfo=timezone.utc).timestamp()
after = datetime(2026, 8, 26, 7, 1, tzinfo=timezone.utc).timestamp()
sent = []
async def following():
return {"items": [{
"repository": "private/project", "kind": "issue", "number": 42,
"title": "Sensitive", "updated_at": "2026-08-25T23:00:00Z",
"has_unseen_change": True,
}]}
async def send(_subscription, payload):
sent.append(json.loads(payload))
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_following_changes(
store, configuration, following, send, now=inside
) == 0
assert sent == []
assert await dispatch_following_changes(
store, configuration, following, send, now=after
) == 1
assert sent[0]["title"] == "1 watched update while alerts were paused"
assert sent[0]["route"] == "#/my-work/following"
@pytest.mark.anyio
async def test_authenticated_device_controls_following_alerts_independently(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -134,6 +233,43 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
assert store.start_day_preferences("session-a")["enabled"] is False
@pytest.mark.anyio
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
async def management_id(_session):
return "session-a"
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.QuietHoursPayload(
enabled=True, start="22:30", end="06:45", timezone="America/New_York"
)
result = await main.update_quiet_hours(payload, request)
assert result == {
"quiet_hours_enabled": True,
"quiet_hours_start": "22:30",
"quiet_hours_end": "06:45",
"quiet_hours_timezone": "America/New_York",
}
status = await main.push_status(request)
assert {key: status[key] for key in result} == result
def test_quiet_hours_reject_equal_boundaries_and_invalid_timezone():
with pytest.raises(ValueError):
main.QuietHoursPayload(enabled=True, start="22:00", end="22:00", timezone="UTC")
with pytest.raises(ValueError):
main.QuietHoursPayload(enabled=True, start="22:00", end="07:00", timezone="Moon/Base")
def test_subscription_store_uses_private_filesystem_permissions(tmp_path):
state_dir = tmp_path / "push-state"
previous_umask = os.umask(0)
@@ -217,6 +353,7 @@ def test_push_store_factory_only_requires_its_key_when_push_is_enabled(
disabled = factory(tmp_path / "disabled.sqlite3", push_enabled=False)
assert disabled.is_subscribed("session-a") is False
assert disabled.deadline_preferences("session-a")["enabled"] is False
assert disabled.claim_unseen({42: "r1"}, now=0) == []
assert not (tmp_path / "disabled.sqlite3").exists()
with pytest.raises(RuntimeError, match="encryption key"):
factory(tmp_path / "enabled.sqlite3", push_enabled=True)
@@ -1435,6 +1572,10 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_timezone": "UTC",
"start_day_reminder_hour": 9,
"following_enabled": False,
"quiet_hours_enabled": False,
"quiet_hours_start": "22:00",
"quiet_hours_end": "07:00",
"quiet_hours_timezone": "UTC",
"delivery_health": {},
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
+26
View File
@@ -65,6 +65,32 @@ const photo = new Blob(['field-evidence'], {{type:'image/webp'}});
}
def test_search_store_lists_only_confirmed_accounts_photo_drafts_as_metadata():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = [
{{id:'mine',version:1,ownerLogin:'timmy',kind:'pull',repository:'stackchain/dashboard',number:19,updatedAt:220,attachments:[{{filename:'proof.jpg',blob:new Blob(['secret'])}}]}},
{{id:'other',version:1,ownerLogin:'alexander',kind:'issue',repository:'stackchain/private',number:3,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},
{{id:'empty',version:1,ownerLogin:'timmy',kind:'issue',repository:'stackchain/dashboard',number:20,updatedAt:400,attachments:[]}},
];
const transaction=async operation=>operation === 'list' ? records.map(value=>structuredClone(value)) : null;
const store=createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
(async()=>process.stdout.write(JSON.stringify(await store.list())))().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == [{
"id": "mine",
"kind": "photo-reply",
"label": "Search PR photos",
"repository": "stackchain/dashboard",
"number": 19,
"title": "stackchain/dashboard#19",
"photo_count": 1,
"updated_at": 220,
"route": {"kind": "search", "target_kind": "pull", "repository": "stackchain/dashboard", "number": 19},
"photo_store": "search",
}]
def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
+31 -16
View File
@@ -186,10 +186,17 @@ async function dispatchPush(payload) {{
return json.loads(completed.stdout)
def test_shared_progressive_snapshot_broker_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/progressive-live-snapshot.js'" in source
def test_week_unplan_undo_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/week-plan.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -197,20 +204,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
def test_private_today_action_mailbox_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/week-plan.js'" in source
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -219,7 +226,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/authored-outbox.js'" in source
assert "BASE + 'static/background-issue-sync.js'" in source
@@ -228,7 +235,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/issue-evidence-review.js'" in source
assert "BASE + 'static/issue-attachment.js'" in source
@@ -236,14 +243,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -251,7 +258,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -259,7 +266,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/issue-sheet.js'" in source
assert "BASE + 'static/checklist-conflict.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -269,14 +276,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -285,21 +292,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -1354,7 +1361,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -1373,6 +1380,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/manifest.webmanifest",
"/dashboard/static/dashboard.css",
"/dashboard/static/dashboard.js",
"/dashboard/static/human-gates.js",
"/dashboard/static/progressive-human-gates.js",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
@@ -1389,6 +1398,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-reply-draft-store.js",
"/dashboard/static/conversation-reply-draft-store.js",
"/dashboard/static/conversation-photo-drafts.js",
"/dashboard/static/photo-draft-inbox.js",
"/dashboard/static/search-defer.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
@@ -1407,6 +1417,10 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/progressive-live-snapshot.js",
"/dashboard/static/progressive-my-work.js",
"/dashboard/static/progressive-mobile-dock.js",
"/dashboard/static/progressive-capture.js",
"/dashboard/static/agenda-replan.js",
"/dashboard/static/agenda-calendar.js",
"/dashboard/static/protect-today.js",
@@ -1505,6 +1519,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/device-storage.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-search-modal.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",
"/dashboard/static/push-notifications.js",
+29
View File
@@ -122,6 +122,35 @@ process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:t
}
def test_reload_running_checkpoint_cannot_restart_item_during_valid_local_break():
script = TIMER.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000});
timer.activate('issue:r:42:');
timer.startBreak(5);
const adopted = timer.adopt('issue:r:42:', 12000, true, [
{identity:'issue:r:42:',elapsed_ms:12000},
]);
process.stdout.write(JSON.stringify({adopted,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
"""
assert run_node(script) == {
"adopted": True,
"snapshot": {
"identity": "issue:r:42:",
"elapsed_ms": 12000,
"running": False,
"break_deadline_at": 400_000,
},
"pending": {
"identity": "issue:r:42:",
"deadline_at": 400_000,
"expired": False,
},
}
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r"""
const values = new Map();
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v139';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v145';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v139" in source
assert "stackchain-dashboard-shell-v145" in source
assert "BASE + 'static/today-sync.js'" in source
+67
View File
@@ -326,6 +326,73 @@ console.log(JSON.stringify({message,adopted,pending:controller.pending(),remaini
assert result["remaining"] == 1
def run_mounted_confirmation(*, fail_move: bool = False) -> dict:
failure = "true" if fail_move else "false"
scenario = """
const mount=require('./frontend/today-week-reschedule.js').mount;
class Element {
constructor(){this.listeners={};this.dataset={};this.children=[];this.open=false;this.disabled=false;this.value='';this.textContent='';}
addEventListener(type,handler){(this.listeners[type] ||= []).push(handler);}
async emit(type,event={preventDefault(){}}){for(const handler of this.listeners[type]||[])await handler(event);}
append(...children){this.children.push(...children);}
replaceChildren(...children){this.children=children;}
querySelectorAll(selector){return selector==='button' ? this.children : [];}
querySelector(){return this.children.find(child=>!child.disabled)||null;}
setAttribute(){}
showModal(){this.open=true;}
close(){this.open=false;}
focus(){}
click(){return this.emit('click');}
}
const selectors={
'#today-week-reschedule':new Element(), '#today-week-reschedule-days':new Element(),
'#today-week-reschedule-estimate':new Element(), '#today-week-reschedule-status':new Element(),
'#confirm-today-week-reschedule':new Element(), '[data-work-session-reschedule-week]':new Element(),
'#cancel-today-week-reschedule':new Element(), '[data-mobile-today-more]':new Element(),
};
const document={hidden:false,addEventListener(){},createElement:()=>new Element()};
const window={addEventListener(){},setTimeout(){}};
let finishMove,failMove;
const move=new Promise((resolve,reject)=>{finishMove=resolve;failMove=reject;});
const today={revision:4,ids:['active'],estimates:{active:30}};
const weekState={revision:7,offline_snapshot:false,days:[]};
const week={load:async()=>weekState,review:()=>({days:[{
plan_date:'2026-08-27',label:'Thu',ids:[],planned_minutes:0,capacity_minutes:90,eligible:true,
}]}),adopt(){}};
const api=async(url)=>url==='api/v1/today' ? today : move;
mount({qs:selector=>selectors[selector],document,window,week,api,getToday:()=>today,
adoptToday(){},currentTarget:()=>({identity:'active'}),closeActions(){},refresh:async()=>{},warm(){},
continueToday:async()=>{},announce(){},schedule:callback=>callback()});
await selectors['[data-work-session-reschedule-week]'].emit('click');
await selectors['#today-week-reschedule-days'].children[0].emit('click');
const confirming=selectors['#confirm-today-week-reschedule'].emit('click');
await Promise.resolve();
const openWhileSaving=selectors['#today-week-reschedule'].open;
if(__FAIL_MOVE__){
const error=new Error('Move unavailable. Try again.');error.status=500;failMove(error);
}else{
finishMove({today:{revision:5,ids:[],estimates:{}},week:{revision:8,days:[]}});
}
await confirming;
console.log(JSON.stringify({openWhileSaving,openAfterSettled:selectors['#today-week-reschedule'].open}));
"""
return run_controller(scenario.replace("__FAIL_MOVE__", failure))
def test_reschedule_dialog_closes_as_soon_as_a_valid_move_is_confirmed():
assert run_mounted_confirmation() == {
"openWhileSaving": False,
"openAfterSettled": False,
}
def test_reschedule_dialog_reopens_with_retry_state_when_confirm_fails():
assert run_mounted_confirmation(fail_move=True) == {
"openWhileSaving": False,
"openAfterSettled": True,
}
def test_mobile_active_today_reschedule_dialog_is_touch_safe_and_wired_into_release_bundle():
index = INDEX.read_text()
css = CSS.read_text()
+2 -1
View File
@@ -82,6 +82,7 @@ async def test_dashboard_mounts_mobile_new_activity_positioning_flow():
assert '<script src="static/update-read-position.js"></script>' in html
assert 'id="jump-update-new-activity"' in html
assert "updateReadPosition.open(String(item.notification_id))" in html
assert "updateReadPosition.ready(String(selectedUpdate?.notification_id || ''), newest)" in html
source = html + (POSITIONER.parent / "conversation.js").read_text()
assert "updateReadPosition.ready(String(getSelectedUpdate()?.notification_id || ''),newest)" in source
assert ".update-new-activity" in html
assert "BASE + 'static/update-read-position.js'" in (POSITIONER.parent / "service-worker.js").read_text()
+30 -4
View File
@@ -24,6 +24,7 @@ def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
const status={textContent:''};
const document={
querySelector(selector) {
if (selector === 'meta[name="stackchain-feature-work-core"]') return {content:'feature-work-core-123.js'};
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
if (selector === '#my-work-action-status') return status;
@@ -37,6 +38,7 @@ console.log(JSON.stringify({requested,status:status.textContent}));
""")
assert result == {
"requested": [
"work-core:feature-work-core-123.js",
"today-timer:feature-workspace-abc.js",
"planning:feature-planning-def.js",
],
@@ -44,6 +46,30 @@ console.log(JSON.stringify({requested,status:status.textContent}));
}
def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate():
result = run_bootstrap("""
const document={querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
return match ? {content:'feature-' + match[1] + '.js'} : null;
}};
const requested=[]; const releases={};
const createLoader=()=>({load:name=>{
requested.push(name);
if (name === 'work-core') return Promise.resolve();
return new Promise(resolve=>{releases[name]=resolve;});
}});
const lifecycle=await loadWorkspace({document,createLoader});
const returned=requested.slice();
releases['today-timer'](); releases.planning();
await lifecycle.optionalReady;
console.log(JSON.stringify({returned,settled:requested}));
""")
assert result == {
"returned": ["work-core", "today-timer", "planning"],
"settled": ["work-core", "today-timer", "planning"],
}
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
result = run_bootstrap("""
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
@@ -72,7 +98,7 @@ const document={querySelector(selector) {
return null;
}};
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
await new Promise(resolve=>setImmediate(resolve));
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
@@ -81,7 +107,7 @@ await Promise.all([first,second,loading]);
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {
"attempts": 6,
"attempts": 5,
"reloads": 0,
"offered": {
"hidden": False,
@@ -116,7 +142,7 @@ const window={
addEventListener(name,callback) { listeners[name]=callback; },
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
};
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
await new Promise(resolve=>setImmediate(resolve));
const waiting=Boolean(listeners.online);
@@ -125,7 +151,7 @@ await loading;
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
""")
assert result == {
"attempts": 6,
"attempts": 5,
"waiting": True,
"reloads": 0,
"status": "",