Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679639394f | ||
|
|
3b374cbb50 | ||
|
|
ad5e50eb48 | ||
|
|
4b8d0c4504 | ||
|
|
6e3b6804c1 | ||
|
|
c31129f1cc | ||
|
|
4afe4dd3b9 | ||
|
|
985472eb06 | ||
|
|
8b36455c21 | ||
|
|
3ba1fe76f0 | ||
|
|
321474e8cf | ||
|
|
166d14dd35 | ||
|
|
7a62f7ead7 | ||
|
|
461f90c8ff | ||
|
|
9ea590a65c | ||
|
|
33a6d1bb6d | ||
|
|
b9ae9c6c83 | ||
|
|
45ceedb679 | ||
|
|
2e2a7821ae | ||
|
|
33427d2407 | ||
|
|
f0b0b703ff | ||
|
|
055a9dbb2a | ||
|
|
1f7fd093fd | ||
|
|
ff0e47cbe7 | ||
|
|
c2da869c47 | ||
|
|
8ada75ec33 | ||
|
|
6dea5fd1ef | ||
|
|
bf4ce3adad | ||
|
|
34664edb8b | ||
|
|
f92794f571 | ||
|
|
edbe2d76dd | ||
|
|
cbdddcddbe | ||
|
|
de6c9c249a | ||
|
|
19122c69e6 | ||
|
|
2253c40577 | ||
|
|
5c2c0383df | ||
|
|
4e13f5482b | ||
|
|
51278bc86f | ||
|
|
a919242b8f | ||
|
|
56a9a0cba6 | ||
|
|
27fc74ced6 | ||
|
|
f686e2e71e | ||
|
|
8f7a836d28 | ||
|
|
5894eafb3f | ||
|
|
639ce911be | ||
|
|
475acbb92f | ||
|
|
c0fc4eddd0 | ||
|
|
1bf759dc76 | ||
|
|
9441191c18 | ||
|
|
76bea83537 | ||
|
|
7775c3e1f6 | ||
|
|
98249ad9ed | ||
|
|
901691a93b | ||
|
|
6dfc02158d | ||
|
|
454022ee89 | ||
|
|
4bc2881a4b | ||
|
|
3c743a2b71 | ||
|
|
7ec3980713 | ||
|
|
d940e0b37b | ||
|
|
4610afd489 | ||
|
|
020106f001 | ||
|
|
3384e56cb8 | ||
|
|
229d112297 | ||
|
|
34029e624f | ||
|
|
0ff6b5c8fd | ||
|
|
391e8964c4 | ||
|
|
990f0f04d6 | ||
|
|
a995e3b45f | ||
|
|
ac3d1f32d1 | ||
|
|
e11e55149c | ||
|
|
ae6cde29c2 | ||
|
|
0539b0a035 | ||
|
|
a2ba624dc2 | ||
|
|
e78ceb1bf7 | ||
|
|
ac7a716e5c | ||
|
|
fa4ad28961 | ||
|
|
3726360baa | ||
|
|
5d326c30a1 | ||
|
|
2cb92811c9 | ||
|
|
9e68382bd7 | ||
|
|
b4dc785dd8 | ||
|
|
a9051d843c | ||
|
|
4969cadd82 | ||
|
|
bf96560831 | ||
|
|
7342b2e6de | ||
|
|
671c88d287 | ||
|
|
503d6f4988 | ||
|
|
eb2c84a6c2 | ||
|
|
fb6b42bcaf | ||
|
|
41eaf51953 | ||
|
|
c9c5939f0a | ||
|
|
34af4ce903 | ||
|
|
7034ec55ed | ||
|
|
d46d296e09 | ||
|
|
21e0a49d3c | ||
|
|
160753caf8 | ||
|
|
0ea264a942 | ||
|
|
d6c7a495ed | ||
|
|
dc18004e02 | ||
|
|
b5858046ee | ||
|
|
b5b6b4d0a9 | ||
|
|
6143568b95 | ||
|
|
9a152c435e | ||
|
|
80a1fa60bd | ||
|
|
8ba4d48549 | ||
|
|
c0b42b71cf | ||
|
|
d026bbd400 | ||
|
|
9189d13db9 | ||
|
|
9786724678 | ||
|
|
6b99d4f171 | ||
|
|
7d633e67e8 | ||
|
|
bd5e66b4e5 | ||
|
|
add45365ab | ||
|
|
6138b1f24e | ||
|
|
40c66df143 | ||
|
|
7356530318 | ||
|
|
9f6476a5b4 | ||
|
|
de8227f4f4 | ||
|
|
ccf5f69400 | ||
|
|
207df76b69 | ||
|
|
43c7a85eec | ||
|
|
d0b8c3dc0d | ||
|
|
6e6e63e553 | ||
|
|
54029d2668 | ||
|
|
1f57b75d5d | ||
|
|
fefbe7e084 | ||
|
|
31a07e4a46 | ||
|
|
b458dbaa78 | ||
|
|
8c4e729c4a |
No files matched your search
+61
-5
@@ -17,13 +17,69 @@ jobs:
|
||||
- run: pip install -r requirements.txt
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-frontend:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Pack frontend
|
||||
run: tar -czf frontend.tar.gz frontend
|
||||
- name: Upload artifact
|
||||
- name: Build deterministic release bundle
|
||||
run: |
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
python3 scripts/build_release.py \
|
||||
--root . \
|
||||
--output-dir dist \
|
||||
--commit "$GITHUB_SHA" \
|
||||
--source-date-epoch "$SOURCE_DATE_EPOCH"
|
||||
- name: Upload tested release bundle
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: frontend, path: frontend.tar.gz }
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build-release]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download tested release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
- name: Verify and publish release candidate
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
(cd dist && sha256sum -c ./*.sha256)
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json > /tmp/release-response.json
|
||||
RELEASE_ID="$(python3 -c 'import json; print(json.load(open("/tmp/release-response.json"))["id"])')"
|
||||
|
||||
for ASSET in dist/*; do
|
||||
NAME="$(basename "$ASSET")"
|
||||
ENCODED_NAME="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")"
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL/$RELEASE_ID/assets?name=$ENCODED_NAME" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$ASSET"
|
||||
done
|
||||
|
||||
printf '{"draft":false,"prerelease":true}\n' > /tmp/publish.json
|
||||
curl --fail-with-body -sS -X PATCH "$RELEASE_URL/$RELEASE_ID" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/publish.json
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create Gitea tag and release candidate
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" > /tmp/tag.json
|
||||
curl --fail-with-body -sS -X POST "$TAG_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/tag.json
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json
|
||||
@@ -21,14 +21,60 @@ inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission. The New issue sheet can optionally select an open
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
when search is unavailable. The sheet can also optionally select an open
|
||||
repository milestone and due date; the dashboard validates both and sends them with
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. Issue capture and authored mobile actions (issue
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
changing a newer Today or Later plan. After a healthy, fully paginated
|
||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
||||
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
||||
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
||||
`updated_at` revisions make no detail request, transient failures retain the prior copy,
|
||||
and pull-request diffs remain online-only. Saved unread-update conversations remain
|
||||
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
|
||||
deduplicated acknowledgement to the durable background delivery system, removes the
|
||||
update from the local queue immediately, and opens the next saved conversation. A cold
|
||||
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
||||
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
|
||||
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
|
||||
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
|
||||
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
|
||||
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
|
||||
not changed by this session boundary. Installed-app
|
||||
navigations are also deadline-bounded: after four seconds without a network response,
|
||||
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
||||
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
||||
Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
@@ -82,19 +128,55 @@ shared writable storage. `X-Forwarded-For` is ignored unless the immediate peer
|
||||
inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only networks you operate. Without
|
||||
that setting, a reverse proxy is safely treated as one shared source.
|
||||
|
||||
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
||||
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||
requests under `/api/v1/` are capped at 64 KiB. Both declared and streamed bodies
|
||||
are counted. Oversized requests receive a compact, non-cacheable HTTP 413 response;
|
||||
validation errors expose field locations and messages but never echo submitted
|
||||
values or validation context. Preserve these limits at the reverse proxy or enforce
|
||||
equal or tighter upstream limits.
|
||||
|
||||
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
||||
kept in the SQLite session registry. Keep that registry on persistent, writable
|
||||
storage shared by all dashboard workers. **Sign out & clear this device** revokes
|
||||
only the current session before clearing browser state, so a copied cookie cannot
|
||||
be replayed afterward; other signed-in devices remain active. **Sign out all
|
||||
devices** is a separately confirmed lost-device safety action that atomically
|
||||
revokes every existing operator session before clearing the current browser and
|
||||
returning to sign-in. Deploying this version invalidates older cookies that do not
|
||||
contain a registered identifier, so operators must sign in once again. Registry
|
||||
read or write failures return a sanitized HTTP 503 before Gitea is contacted.
|
||||
storage shared by all dashboard workers. Operators name a device at sign-in and can
|
||||
open **Active devices** to review creation/expiry times, identify the current device,
|
||||
and revoke one remote session without interrupting other trusted devices. The API
|
||||
exposes only independent management IDs and bounded labels—never cookie values,
|
||||
session hashes, CSRF proofs, or source addresses. Existing two-column registries are
|
||||
migrated in place and their live sessions remain valid.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require the operator access token again.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
first use. Expired, replayed, cross-session, and target-substituted grants fail before
|
||||
Gitea or session state is changed. The browser preserves the pending request and
|
||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||
|
||||
If an active session expires, the first authenticated API rejection replaces the
|
||||
dashboard with sign-in and explains that private drafts remain on the device; signing
|
||||
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
|
||||
state. A selectively or globally revoked device instead receives a bounded revocation
|
||||
reason: on its next server contact, Stackchain clears its owned local/session storage,
|
||||
private outbox database, and dashboard caches before enabling sign-in. Revocation
|
||||
blocks server access immediately, but no web application can erase a device that
|
||||
remains offline forever. **Sign out & clear this device** revokes only the current
|
||||
session before clearing browser state, so a copied cookie cannot be replayed
|
||||
afterward; other signed-in devices remain active. **Sign out all devices** is a
|
||||
separately confirmed lost-device safety action that atomically revokes every existing
|
||||
operator session before clearing the current browser and returning to sign-in.
|
||||
Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted.
|
||||
|
||||
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
||||
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
||||
Every response also defines the browser execution boundary with a Content Security
|
||||
Policy that allows scripts only from the dashboard origin, denies framing and
|
||||
plugins, and blocks unused browser capabilities. Keep these response headers when
|
||||
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
|
||||
is assembled in source order into one content-addressed JavaScript response. Dashboard
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
@@ -136,10 +218,12 @@ below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
|
||||
can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the
|
||||
sections that changed.
|
||||
revision tokens. Each bounded opaque token includes a per-process generation, so a token
|
||||
from another worker or from before a restart cannot suppress different content. The browser
|
||||
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
|
||||
while still returning current freshness and retry metadata. The client retains omitted data
|
||||
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
|
||||
rejected before any upstream work.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
@@ -149,13 +233,20 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
My Work also has a local **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
||||
Deferred items leave normal and Attention queues without marking notifications read
|
||||
or changing any Gitea issue or pull request. They automatically return to their
|
||||
existing priority position at the wake time, and **Bring back now** restores them
|
||||
early. Later state is stored only in this browser, scoped to the confirmed Gitea
|
||||
login, and removed when fully loaded work confirms that an item no longer exists.
|
||||
early. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||
account plan and reports the conflict instead of silently overwriting it. Unrelated
|
||||
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
|
||||
default durable store at `.stackchain-state/later.sqlite3`.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
@@ -168,11 +259,26 @@ dashboard keeps polling until it can restore its live snapshot automatically.
|
||||
Users can explicitly enable **Keep My Work available offline**. Each healthy live
|
||||
refresh then stores a seven-day, versioned snapshot containing only the signed-in
|
||||
user identity and queue-card metadata for issues, pull requests, unread updates,
|
||||
and pagination totals. Bodies, comments, diffs, credentials, repository catalogs,
|
||||
events, and complete API responses are excluded. A cold offline launch labels the
|
||||
saved time and renders this snapshot read-only; opening live details, pagination,
|
||||
and server mutations remain disabled until reconnection. **Clear offline work
|
||||
data** deletes the snapshot, and opting out deletes it automatically.
|
||||
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
|
||||
newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
||||
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
||||
per file. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
This account-bound cache is limited to ten records across all detail kinds; credentials,
|
||||
repository catalogs, events, raw patches, and complete API responses are excluded.
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
until reconnection. Cached
|
||||
requested reviews open in the existing review sheet so file progress, notes, summary,
|
||||
decision, and inline-comment drafts remain usable under the saved head SHA. Review
|
||||
submission is never queued: reconnect reloads the current head before enabling submit;
|
||||
a changed head starts clean SHA-scoped progress while retaining the prior-head draft.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
work data** deletes both stores, and opting out or seven-day expiry deletes them
|
||||
automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
@@ -241,6 +347,10 @@ python3 -m src.release_engine \
|
||||
--repo stackchain/stackchain-dashboard \
|
||||
--agent timmy \
|
||||
--issue 19 \
|
||||
--agent-timeout 1800 \
|
||||
--test-timeout 900 \
|
||||
--termination-grace 5 \
|
||||
--agent-env AGENT_CONFIG_HOME \
|
||||
--test-command 'python3 -m pytest tests/ -q'
|
||||
```
|
||||
|
||||
@@ -249,6 +359,42 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
|
||||
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
||||
do not run without `--issue` when processing a preselected ticket.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Full behavior and
|
||||
safety gates are documented in
|
||||
Agent and test commands use POSIX argument quoting and run as argument vectors,
|
||||
not through a shell. Issue context is available to the agent only through
|
||||
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
|
||||
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
|
||||
text. Shell substitutions, redirects, and pipelines are intentionally not
|
||||
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
|
||||
configure that script as `RELEASE_AGENT_COMMAND` instead.
|
||||
|
||||
Every child process receives an explicit least-privilege environment rather
|
||||
than inheriting the worker's credentials. Coding agents receive a minimal
|
||||
runtime environment plus the five `RELEASE_*` context values above. Tests and
|
||||
Git receive only the minimal runtime environment, so `GITEA_TOKEN` and
|
||||
unrelated parent secrets remain in the release-engine process. If an agent
|
||||
runtime needs another non-secret setting, allow it explicitly with a repeated
|
||||
`--agent-env NAME` option or the comma-separated `RELEASE_AGENT_ENV` variable.
|
||||
`GITEA_TOKEN`, reserved `RELEASE_*` names, and malformed names are rejected
|
||||
before the issue is claimed. Secret-bearing values must not be allowlisted.
|
||||
|
||||
Agent and test deadlines are independent. On expiry, the engine terminates the
|
||||
command's entire process group, escalates from `SIGTERM` to `SIGKILL` after the
|
||||
configured grace period, and records `agent_timed_out` or `tests_timed_out`
|
||||
without advancing the release. The equivalent environment variables are
|
||||
`RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and
|
||||
`RELEASE_TERMINATION_GRACE`; every value must be positive.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Successful checkpoints
|
||||
record the repository, issue branch, and exact commit SHA. A restart resumes at
|
||||
tests, push, or PR creation only after validating that identity and a clean
|
||||
worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations
|
||||
are serialized by an OS-backed, non-blocking lease at
|
||||
`.release-engine/run.lock` under the canonical repository path. If an hourly
|
||||
run overlaps a manual or slow prior run, the contender exits with `release run
|
||||
already active` and owner diagnostics before Gitea discovery or any Git/agent
|
||||
work. The kernel releases ownership when the process exits, so never delete a
|
||||
lock file to recover from a crash; stale file metadata cannot retain the lease.
|
||||
Checkpoint replacement uses writer-unique, flushed temporary files and an
|
||||
atomic rename, preventing concurrent writers from colliding or exposing partial
|
||||
JSON. Full behavior and safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
@@ -12,15 +12,29 @@ Convert one eligible Gitea issue into a tested, traceable pull request without d
|
||||
- Repository key (`owner/repo`).
|
||||
- Agent username.
|
||||
- Optional explicit issue number; otherwise deterministic queue selection.
|
||||
- Coding-agent command template.
|
||||
- Test command.
|
||||
- Coding-agent command, parsed into an argument vector.
|
||||
- Test command, parsed into an argument vector.
|
||||
- Positive, independent coding-agent and test deadlines plus a termination grace period.
|
||||
- Optional explicit allowlist of non-secret parent environment variables needed by the coding-agent runtime.
|
||||
- Local repository path and durable state-file path.
|
||||
|
||||
## State machine
|
||||
|
||||
`discovered → claimed → agent_complete → tests_passed → pushed → pr_opened`
|
||||
|
||||
Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
|
||||
Terminal failure states include `claim_failed`, `agent_failed`, `agent_timed_out`, `tests_failed`, `tests_timed_out`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
|
||||
|
||||
Successful checkpoints are schema-versioned and bind the repository, issue,
|
||||
deterministic branch, and exact commit SHA. On restart, the engine:
|
||||
|
||||
- resumes `agent_complete` at tests;
|
||||
- resumes `tests_passed` at push; and
|
||||
- resumes `pushed` at PR creation after verifying the remote branch SHA.
|
||||
|
||||
Before skipping any stage, it checks out the recorded branch, verifies its
|
||||
local `HEAD`, and requires a clean worktree. Missing identity, an unsupported
|
||||
schema, or any repository/issue/branch/SHA mismatch fails closed instead of
|
||||
repeating or advancing delivery.
|
||||
|
||||
## Queue selection
|
||||
|
||||
@@ -30,6 +44,19 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
4. Otherwise sort by priority label (`P0`, `P1`, `P2`, unlabeled) then issue number.
|
||||
5. Select exactly one issue per invocation.
|
||||
|
||||
## Single-active-run lease
|
||||
|
||||
- Before issue discovery, every invocation acquires a non-blocking advisory lock
|
||||
at `.release-engine/run.lock` under the canonical repository path.
|
||||
- The lease is held through the final checkpoint and pull-request request. A
|
||||
contended invocation fails immediately with `release run already active`
|
||||
before contacting Gitea or running Git, agent, or test commands.
|
||||
- The lock file records bounded owner PID and acquisition-time diagnostics.
|
||||
These are informational only: kernel lock ownership is authoritative, so a
|
||||
killed or crashed process releases the lease automatically and stale metadata
|
||||
cannot block the next run.
|
||||
- Different canonical repository paths use independent leases.
|
||||
|
||||
## Claiming
|
||||
|
||||
- PATCH the issue with the configured assignee.
|
||||
@@ -39,9 +66,18 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
## Execution
|
||||
|
||||
- Branch format: `<agent>/<issue>-<slug>`.
|
||||
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
|
||||
- Coding and test commands are parsed with POSIX argument quoting and executed directly without a shell.
|
||||
- Issue number, title, body, repo, and branch are supplied to the coding agent only through `RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`, `RELEASE_REPO`, and `RELEASE_BRANCH` environment variables. Gitea-controlled content is never interpolated into executable syntax.
|
||||
- Child processes never inherit the complete parent environment. Coding receives a minimal runtime environment, explicitly allowlisted non-secret agent variables, and the five issue-context values. Tests and Git receive only the minimal runtime environment; `GITEA_TOKEN` remains parent-process-only.
|
||||
- Agent variables are allowlisted with repeated `--agent-env NAME` options or comma-separated `RELEASE_AGENT_ENV`. `GITEA_TOKEN`, `RELEASE_*`, and malformed names fail validation before claim.
|
||||
- Shell operators, substitutions, and pipelines are not interpreted. Operators that are intentionally required must live in a separately reviewed wrapper script configured as the command.
|
||||
- Empty or malformed commands abort before the issue claim.
|
||||
- Non-zero coding-agent exit blocks tests and PR creation.
|
||||
- Tests run using the configured command; stdout/stderr and exit code become evidence.
|
||||
- Coding-agent and test commands each run in a new process session. Their deadlines default to 1800 and 900 seconds, respectively.
|
||||
- On expiry, the engine sends `SIGTERM` to the command's entire process group, waits for the termination grace period (5 seconds by default), then sends `SIGKILL` to the group and reaps the command. This prevents descendants from keeping the worker wedged.
|
||||
- Deadlines are configured with `--agent-timeout`, `--test-timeout`, and `--termination-grace`, or `RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and `RELEASE_TERMINATION_GRACE`. Non-positive values abort before issue discovery or claim.
|
||||
- Timeout evidence is bounded in durable state. An agent timeout blocks tests; a test timeout blocks push and PR creation.
|
||||
|
||||
## PR and release gate
|
||||
|
||||
@@ -55,6 +91,12 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
|
||||
- `--dry-run` performs discovery and planning only: no claim, git mutation, agent command, push, or PR.
|
||||
- State is written atomically after each successful transition.
|
||||
- Each checkpoint write uses a writer-unique temporary file, flushes file data,
|
||||
atomically replaces the checkpoint, and flushes its parent directory. A
|
||||
failed or concurrent writer therefore cannot collide on a shared temp path or
|
||||
expose partial JSON.
|
||||
- Resumable state carries repository and commit identity; successful stages are
|
||||
skipped only after local (and, after push, remote) Git verification.
|
||||
- One invocation handles at most one issue.
|
||||
- Missing token, dirty worktree, failed claim verification, failed tests, or missing evidence blocks PR creation.
|
||||
|
||||
@@ -68,3 +110,13 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
6. Passing tests produce a linked PR request with evidence.
|
||||
7. Existing state/PR prevents duplicate work.
|
||||
8. Dry-run against live Gitea returns a plan and performs no mutation.
|
||||
9. A timed-out command terminates its descendants and returns within its deadline plus grace period.
|
||||
10. Agent and test timeouts persist distinct terminal states and block every later delivery stage.
|
||||
11. Non-positive deadline configuration aborts before issue claim.
|
||||
12. Restarts from `agent_complete`, `tests_passed`, and `pushed` skip only the
|
||||
completed stages and open exactly one linked PR.
|
||||
13. Dirty or identity-mismatched checkpoints stop before push or PR creation.
|
||||
14. Parent credentials are absent from coding-agent, test, and Git subprocesses while explicit agent variables and issue context reach only the coding stage.
|
||||
15. Reserved or malformed agent environment allowlist entries abort before claim.
|
||||
16. An overlapping invocation fails before Gitea discovery or local command execution and reports bounded owner diagnostics.
|
||||
17. Concurrent checkpoint-save stress completes without temp-file collisions or partial state.
|
||||
@@ -0,0 +1,49 @@
|
||||
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
|
||||
let request = null;
|
||||
|
||||
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
|
||||
if (request) return request;
|
||||
if (!available()) {
|
||||
announce('Today is full—remove an item before assigning this issue.');
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
request = Promise.resolve()
|
||||
.then(() => alreadyOwned ? item : claim(item))
|
||||
.then(confirmed => {
|
||||
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
|
||||
if (outcome === 'queued') {
|
||||
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
|
||||
'Assigned and queued in Today. Keep finding work when ready.');
|
||||
return outcome;
|
||||
}
|
||||
if (outcome === 'started') {
|
||||
announce(alreadyOwned ? 'Added to Today and ready to work.' :
|
||||
'Assigned, added to Today, and ready to work.');
|
||||
return outcome;
|
||||
}
|
||||
if (destination === 'queue') {
|
||||
if (outcome === 'sync-unavailable') {
|
||||
announce(alreadyOwned ?
|
||||
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
|
||||
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not be queued. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
|
||||
}
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not start. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not start. The issue is open so you can recover.');
|
||||
}
|
||||
recover(confirmed);
|
||||
return 'recovery';
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
|
||||
@@ -77,6 +77,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
status: 'queued',
|
||||
};
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}));
|
||||
return updated;
|
||||
@@ -134,6 +135,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
@@ -170,7 +172,17 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
const queued = item.status === 'attention' ? update(id, item) : item;
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(makeId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
@@ -189,6 +201,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
...item,
|
||||
status: 'attention',
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
|
||||
@@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
request.result.createObjectStore('issues', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return databasePromise;
|
||||
@@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return async work => {
|
||||
const transact = async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('issues', 'readwrite');
|
||||
@@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
});
|
||||
});
|
||||
};
|
||||
transact.close = async () => {
|
||||
if (!databasePromise) return;
|
||||
const db = await databasePromise;
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
@@ -82,6 +96,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
async function claimBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
|
||||
authored: eligible.filter(item => item.outboxLane === 'authored'),
|
||||
};
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift());
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
|
||||
}
|
||||
const claimed = selected.map(item => ({
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs,
|
||||
}));
|
||||
for (const item of claimed) await records.put(item);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claim(id, ownerLogin) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
@@ -139,9 +179,16 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
upsert,
|
||||
claim,
|
||||
claimNext,
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
claimBatch,
|
||||
complete: (id, deliveredIssue) => update(id, item => ({
|
||||
...item, status: 'sent', claimUntil: 0,
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
@@ -149,14 +196,48 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
setReceiptPreference,
|
||||
getReceiptPreference,
|
||||
close: () => transact.close?.(),
|
||||
};
|
||||
}
|
||||
|
||||
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
function createBackgroundIssueSync({
|
||||
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
|
||||
batch = work => work(), requestTimeoutMs = 15000,
|
||||
}) {
|
||||
let purgeRequested = false;
|
||||
let activeFlush = null;
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
let timer;
|
||||
const interrupted = new Promise((resolve, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error(purgeRequested
|
||||
? 'Background request canceled.'
|
||||
: 'Background request timed out.'));
|
||||
}, { once: true });
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
|
||||
interrupted,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
activeRequests.delete(controller);
|
||||
}
|
||||
}
|
||||
function receiptFor(item, status, delivered = {}) {
|
||||
if (status === 'attention') {
|
||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
||||
}
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
@@ -169,6 +250,12 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
}
|
||||
|
||||
function deliveryRequest(item) {
|
||||
if (item.kind === 'notification-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
@@ -218,8 +305,8 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await fetchJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
@@ -229,7 +316,11 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
throw error;
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(item.id, String(error?.message || 'Issue needs attention').slice(0, 240));
|
||||
await store.fail(
|
||||
item.id,
|
||||
String(error?.message || 'Issue needs attention').slice(0, 240),
|
||||
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
||||
);
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
@@ -238,6 +329,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (purgeRequested) return { blocked: true };
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
await store.upsert(item);
|
||||
const claimed = await store.claim(item.id, currentLogin);
|
||||
@@ -245,8 +337,8 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
return deliver(claimed);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
const identity = await fetchJson(base + 'api/v1/background-identity', {
|
||||
async function runFlush() {
|
||||
const identity = await requestJson(base + 'api/v1/background-identity', {
|
||||
headers: { Accept: 'application/json' }, cache: 'no-store',
|
||||
});
|
||||
const login = String(identity?.login || '').trim();
|
||||
@@ -254,21 +346,66 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
|
||||
while (true) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
const result = await deliver(item);
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < claimed.length) {
|
||||
const item = claimed[next++];
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw new Error('Background delivery canceled.');
|
||||
}
|
||||
if (authenticationError) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw authenticationError;
|
||||
}
|
||||
if (transientError) throw transientError;
|
||||
} else {
|
||||
while (!purgeRequested) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
collect(await deliver(item));
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
|
||||
if (activeFlush) return activeFlush;
|
||||
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
purgeRequested = true;
|
||||
activeRequests.forEach(controller => controller.abort());
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send,
|
||||
flush, send, purge,
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createCardPlanning = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
|
||||
function sync(disclosure) {
|
||||
disclosure.querySelector('summary')?.setAttribute(
|
||||
'aria-expanded', String(disclosure.open)
|
||||
);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
|
||||
disclosures.forEach(disclosure => {
|
||||
if (disclosure.dataset.cardPlanningBound === 'true') return;
|
||||
disclosure.dataset.cardPlanningBound = 'true';
|
||||
sync(disclosure);
|
||||
disclosure.addEventListener('toggle', () => {
|
||||
if (disclosure.open) {
|
||||
disclosures.forEach(other => {
|
||||
if (other === disclosure || !other.open) return;
|
||||
other.open = false;
|
||||
sync(other);
|
||||
});
|
||||
}
|
||||
sync(disclosure);
|
||||
});
|
||||
disclosure.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
disclosure.open = false;
|
||||
sync(disclosure);
|
||||
disclosure.querySelector('summary')?.focus();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { wire };
|
||||
});
|
||||
@@ -1,3 +1,15 @@
|
||||
function buildLiveRevisionQuery(revisions = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
|
||||
['context', 'events', 'notifications'].forEach((section) => {
|
||||
const revision = revisions[section];
|
||||
if (typeof revision === 'string' && tokenPattern.test(revision)) {
|
||||
params.set(section + '_revision', revision);
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
@@ -5,9 +17,12 @@ function createContextPoller({
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
setDeadlineTimer = setTimeout,
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
@@ -27,18 +42,55 @@ function createContextPoller({
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
function abortError() {
|
||||
const error = new Error('Live update superseded.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function supersede(request) {
|
||||
if (!request || request.settled) return;
|
||||
request.superseded = true;
|
||||
request.controller.abort();
|
||||
request.rejectDeadline(abortError());
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
if (activeRequest && !options.force) return activeRequest.promise;
|
||||
if (activeRequest) supersede(activeRequest);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectDeadline;
|
||||
const deadlinePromise = new Promise((resolve, reject) => {
|
||||
rejectDeadline = reject;
|
||||
});
|
||||
const requestState = {
|
||||
controller,
|
||||
deadline: null,
|
||||
promise: null,
|
||||
rejectDeadline,
|
||||
settled: false,
|
||||
superseded: false,
|
||||
};
|
||||
activeRequest = requestState;
|
||||
requestState.deadline = setDeadlineTimer(() => {
|
||||
if (requestState.settled || requestState.superseded) return;
|
||||
controller.abort();
|
||||
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
|
||||
error.name = 'TimeoutError';
|
||||
rejectDeadline(error);
|
||||
}, timeoutMs);
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext({ ...revisions });
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
@@ -48,20 +100,23 @@ function createContextPoller({
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
if (activeRequest === requestState && !stopped) onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
requestState.settled = true;
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
return requestState.promise;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
return refresh({ force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -71,10 +126,17 @@ function createContextPoller({
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
if (activeRequest) {
|
||||
const request = activeRequest;
|
||||
activeRequest = null;
|
||||
supersede(request);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
|
||||
const completed = new Set();
|
||||
|
||||
function available() {
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
if (added === 'full') {
|
||||
announce('Created, but Today changed—remove an item, then add this issue.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
warm();
|
||||
start(issue);
|
||||
announce('Created, added to Today, and ready to work.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
@@ -23,6 +23,10 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
@@ -140,6 +144,66 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
|
||||
.then(payload => ({
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
page: Number(payload?.page) || safePage,
|
||||
total: Math.max(0, Number(payload?.total) || 0),
|
||||
has_more: payload?.has_more === true,
|
||||
}))
|
||||
.finally(() => repositoryPageRequests.delete(safePage));
|
||||
repositoryPageRequests.set(safePage, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
return { repository, title, key: repository + '\n' + title.toLowerCase() };
|
||||
}
|
||||
|
||||
async function findDuplicates(draft) {
|
||||
const input = duplicateKey(draft);
|
||||
const request = ++duplicateRequest;
|
||||
if (!input.repository || input.title.length < 4) {
|
||||
duplicateState = {status: 'idle', key: input.key, candidates: []};
|
||||
return duplicateState;
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {
|
||||
status: 'ready',
|
||||
key: input.key,
|
||||
partial: payload?.partial === true,
|
||||
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
|
||||
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
|
||||
).slice(0, 3),
|
||||
};
|
||||
return duplicateState;
|
||||
} catch (error) {
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
|
||||
return duplicateState;
|
||||
}
|
||||
}
|
||||
|
||||
function needsDuplicateAcknowledgement(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
|
||||
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
|
||||
}
|
||||
|
||||
function acknowledgeDuplicates(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
|
||||
acknowledgedDuplicateKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
@@ -166,7 +230,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.offline-today-readiness[hidden] { display:none; }
|
||||
.offline-today-readiness button { min-height:44px; }
|
||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.install-app-card[hidden] { display:none; }
|
||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
||||
.install-app-card button { min-height:44px; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.plan-today-sheet[hidden] { display:none; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
|
||||
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
|
||||
.plan-preview-actions[hidden] { display:none; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.card-planning { min-width:0; }
|
||||
.card-planning > summary { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:grid; }
|
||||
.card-planning-actions { display:grid; gap:8px; min-width:0; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.later-picker[hidden] { display:none; }
|
||||
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.later-picker-header h2 { margin:0; }
|
||||
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
|
||||
.later-picker-error { min-height:1.4em; color:#fca5a5; }
|
||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-work[hidden] { display:none; }
|
||||
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-file.reviewed { opacity:.72; }
|
||||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||||
.review-note { min-height:88px; margin-top:6px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
|
||||
#continue-review-to-merge { min-height:44px; width:100%; }
|
||||
#continue-review-to-merge[hidden] { display:none; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.review-inline-actions button { min-height:44px; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; }
|
||||
.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; }
|
||||
.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; }
|
||||
.markdown-content ul { padding-left:24px; }
|
||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
|
||||
.create-issue-duplicates[hidden] { display:none; }
|
||||
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
|
||||
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
|
||||
.create-issue-duplicate-card a { color:#93c5fd; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.shared-content-actions button { min-height:44px; }
|
||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.pull-sheet.open { display:flex; }
|
||||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
|
||||
.pull-diff-line { display:block; width:max-content; min-width:100%; }
|
||||
.pull-diff-line.added { color:#86efac; background:#123422; }
|
||||
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
|
||||
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
|
||||
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
|
||||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
.app-menu { position:relative; margin-left:0; }
|
||||
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
|
||||
.app-menu > summary::-webkit-details-marker { display:none; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:none; }
|
||||
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
|
||||
.plan-today-panel { width:100%; border-left:0; padding:14px; }
|
||||
.plan-today-item { grid-template-columns:1fr; }
|
||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
|
||||
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,17 +8,23 @@ function createDetailDefer({
|
||||
formatTime = value => new Date(value).toLocaleString(),
|
||||
}) {
|
||||
return {
|
||||
defer(item, preset) {
|
||||
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
|
||||
if (!item) return false;
|
||||
const until = laterWork.presetUntil(preset);
|
||||
if (!laterWork.defer(item, until)) return false;
|
||||
const result = laterWork.defer(item, until);
|
||||
if (result !== 'deferred') {
|
||||
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (!inSession) close();
|
||||
if (!inSession && closeSheet) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
if (!inSession) focus();
|
||||
if (!inSession && restoreFocus) focus();
|
||||
return true;
|
||||
},
|
||||
defer(item, preset) {
|
||||
return this.deferUntil(item, laterWork.presetUntil(preset));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -101,7 +101,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
@@ -112,8 +112,12 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
|
||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
|
||||
delivery_state: item.deliveryState,
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
@@ -144,7 +148,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_id: item.id,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
|
||||
+160
-3251
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = null;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
}
|
||||
|
||||
function showNativePrompt(event) {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event;
|
||||
options.guidance.hidden = true;
|
||||
options.installButton.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return;
|
||||
const prompt = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
options.installButton.disabled = true;
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
hide();
|
||||
options.installButton.disabled = false;
|
||||
options.status.textContent = 'Installation was not completed.';
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
options.storage.setItem(dismissedKey, '1');
|
||||
hide();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
options.installButton.addEventListener('click', install);
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
return {start};
|
||||
});
|
||||
+77
-11
@@ -8,13 +8,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
|
||||
coordinator?.notify('issue');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items))
|
||||
@@ -39,6 +39,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
@@ -86,9 +87,12 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}), mirror);
|
||||
return updated;
|
||||
@@ -146,12 +150,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { issue };
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
@@ -164,16 +169,26 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (item.status === 'attention' || item.status === 'completion') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
@@ -188,9 +203,29 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
update(id, item);
|
||||
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(operationId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
@@ -202,9 +237,15 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
if (background?.status === 'sent') return [];
|
||||
if (background?.status === 'sent') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
}];
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
@@ -212,7 +253,32 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
function pendingCompletions(currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
if (!login) return [];
|
||||
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
|
||||
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
|
||||
id: item.id,
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
function completeIntent(id, currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
const items = read();
|
||||
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
|
||||
if (!matched) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
@@ -0,0 +1,104 @@
|
||||
function createLaterPicker({
|
||||
now = () => new Date(),
|
||||
history = null,
|
||||
eventTarget = null,
|
||||
onState = () => {},
|
||||
onConfirm = () => false,
|
||||
} = {}) {
|
||||
let active = false;
|
||||
let item = null;
|
||||
let trigger = null;
|
||||
let context = null;
|
||||
let afterClose = null;
|
||||
let committed = false;
|
||||
let started = false;
|
||||
|
||||
function parse(input) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(input || ''));
|
||||
if (!match) return { ok:false, message:'Choose a valid local date and time.' };
|
||||
const parts = match.slice(1).map(Number);
|
||||
const value = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], 0, 0);
|
||||
const sameLocalTime = value.getFullYear() === parts[0] &&
|
||||
value.getMonth() === parts[1] - 1 && value.getDate() === parts[2] &&
|
||||
value.getHours() === parts[3] && value.getMinutes() === parts[4];
|
||||
if (!sameLocalTime) return { ok:false, message:'Choose a valid local date and time.' };
|
||||
if (value <= now()) return { ok:false, message:'Choose a future date and time.' };
|
||||
return { ok:true, value };
|
||||
}
|
||||
|
||||
function formatLocal(value) {
|
||||
const pad = part => String(part).padStart(2, '0');
|
||||
return value.getFullYear() + '-' + pad(value.getMonth() + 1) + '-' + pad(value.getDate()) +
|
||||
'T' + pad(value.getHours()) + ':' + pad(value.getMinutes());
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!active) return false;
|
||||
const restore = trigger;
|
||||
active = false;
|
||||
item = null;
|
||||
trigger = null;
|
||||
context = null;
|
||||
committed = false;
|
||||
onState({ open:false, message:'' });
|
||||
restore?.focus?.();
|
||||
const cleanup = afterClose;
|
||||
afterClose = null;
|
||||
cleanup?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started || !eventTarget) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => {
|
||||
if (active && !event.state?.laterPicker) deactivate();
|
||||
});
|
||||
eventTarget.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open(nextItem, nextTrigger, nextContext = 'card') {
|
||||
if (!nextItem || active) return false;
|
||||
active = true;
|
||||
item = nextItem;
|
||||
trigger = nextTrigger || null;
|
||||
context = nextContext;
|
||||
committed = false;
|
||||
const suggested = new Date(now().getTime() + 60 * 60 * 1000);
|
||||
suggested.setMinutes(Math.ceil(suggested.getMinutes() / 15) * 15, 0, 0);
|
||||
if (history) history.pushState({ ...(history.state || {}), laterPicker:true }, '');
|
||||
onState({ open:true, message:'', value:formatLocal(suggested) });
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (history?.state?.laterPicker) history.back();
|
||||
else deactivate();
|
||||
return true;
|
||||
}
|
||||
|
||||
function submit(input) {
|
||||
if (!active || committed) return false;
|
||||
const result = parse(input);
|
||||
if (!result.ok) {
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
return { parse, start, open, close, submit, current:() => active };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterPicker;
|
||||
@@ -0,0 +1,273 @@
|
||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function validRecords(records) {
|
||||
return records && typeof records === 'object' && !Array.isArray(records);
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = { revision: plan.revision, records: plan.records };
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// Server truth remains usable in this tab when storage is unavailable.
|
||||
}
|
||||
onRemoteRecords?.(plan.records);
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
const activeRecords = records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : 0,
|
||||
})).filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('later');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(records) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let conflictCount = 0;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/later');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/later', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
conflictCount += (plan.rejected_operations || [])
|
||||
.filter(item => item.reason === 'stale_intent').length;
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Later delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' :
|
||||
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
|
||||
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'later' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
|
||||
+29
-9
@@ -1,4 +1,4 @@
|
||||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
|
||||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
|
||||
const prefix = 'stackchain.later-work.v1.';
|
||||
let timer = null;
|
||||
|
||||
@@ -42,11 +42,14 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
|
||||
if (!key) return 'unavailable';
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
records[id] = wake.toISOString();
|
||||
write(records);
|
||||
return true;
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
@@ -66,10 +69,21 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const records = read();
|
||||
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
||||
delete records[id];
|
||||
write(records);
|
||||
if (!write(records)) return false;
|
||||
onChange('restore', id, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
|
||||
function schedule(wakeTimes, current) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
@@ -87,16 +101,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const retained = {};
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
@@ -107,7 +127,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, defer, restore, presetUntil, partition };
|
||||
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
+49
-6
@@ -2,15 +2,52 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createLoginController = factory;
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
const invalid = entries.some(([name, content]) => (
|
||||
!Object.prototype.hasOwnProperty.call(limits, name)
|
||||
|| !content
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||
try {
|
||||
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
|
||||
await clearPrivateDeviceData();
|
||||
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
|
||||
button.disabled = false;
|
||||
} catch (_error) {
|
||||
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.';
|
||||
}
|
||||
}
|
||||
|
||||
function showRetryCountdown(seconds) {
|
||||
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
|
||||
button.disabled = true;
|
||||
@@ -28,14 +65,14 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function submit(accessToken) {
|
||||
async function submit(accessToken, deviceLabel = 'This device') {
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken }),
|
||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||
});
|
||||
} catch (_error) {
|
||||
form.reset();
|
||||
@@ -44,7 +81,7 @@
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
location.replace('./');
|
||||
location.replace(continuation);
|
||||
return;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
@@ -54,23 +91,29 @@
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
return { submit };
|
||||
return { submit, showReason };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const loginParams = new URLSearchParams(window.location.search);
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
continuation: loginParams.get('continue'),
|
||||
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
|
||||
});
|
||||
controller.showReason(loginParams.get('reason'));
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const accessToken = new FormData(form).get('access_token');
|
||||
controller.submit(accessToken);
|
||||
const data = new FormData(form);
|
||||
const accessToken = data.get('access_token');
|
||||
const deviceLabel = data.get('device_label');
|
||||
controller.submit(accessToken, deviceLabel);
|
||||
});
|
||||
}
|
||||
+88
-10
@@ -13,17 +13,95 @@
|
||||
})[character]);
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
return escapeHtml(raw)
|
||||
.replace(/^#{1,6}\s.*$/gm, (line) => {
|
||||
const level = line.match(/^(#{1,6})/)[1].length;
|
||||
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
|
||||
function renderInline(value) {
|
||||
const code = [];
|
||||
let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => {
|
||||
const marker = '\u0000CODE' + code.length + '\u0000';
|
||||
code.push('<code>' + body + '</code>');
|
||||
return marker;
|
||||
});
|
||||
rendered = rendered
|
||||
.replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+"[^&]*?")?\)/g, (_match, label, escapedUrl) => {
|
||||
const url = escapedUrl.replace(/&/g, '&');
|
||||
const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url);
|
||||
if (!safe) return label;
|
||||
return '<a href="' + escapedUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||||
})
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n/g, '<br>');
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||
}
|
||||
|
||||
function renderList(lines) {
|
||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||
const items = lines.map(line => {
|
||||
let body = line.replace(/^[-*+]\s+/, '');
|
||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||
const checked = /^\[[xX]\]\s+/.test(body);
|
||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
|
||||
if (fence) {
|
||||
const body = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
|
||||
body.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
const language = fence[1] ? ' class="language-' + fence[1] + '"' : '';
|
||||
output.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
output.push('<h' + level + '>' + renderInline(heading[2]) + '</h' + level + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote = [];
|
||||
while (index < lines.length && /^>\s?/.test(lines[index])) {
|
||||
quote.push(lines[index].replace(/^>\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
output.push('<blockquote><p>' + renderInline(quote.join('\n')).replace(/\n/g, '<br>') + '</p></blockquote>');
|
||||
continue;
|
||||
}
|
||||
if (/^[-*+]\s+/.test(line)) {
|
||||
const list = [];
|
||||
while (index < lines.length && /^[-*+]\s+/.test(lines[index])) {
|
||||
list.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push(renderList(list));
|
||||
continue;
|
||||
}
|
||||
const paragraph = [];
|
||||
while (index < lines.length && lines[index].trim() &&
|
||||
!/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) &&
|
||||
!/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) {
|
||||
paragraph.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||
}
|
||||
return output.join('');
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileComposerViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileComposerViewport(options) {
|
||||
const entries = options.entries || [];
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const documentRef = options.document || document;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = null;
|
||||
let started = false;
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !viewport || !mediaQuery.matches) return;
|
||||
active.panel.style.setProperty('--composer-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
active.panel.style.setProperty('--composer-viewport-height', `${viewport.height}px`);
|
||||
reveal();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
function activate(entry) {
|
||||
if (!viewport || !mediaQuery.matches) return;
|
||||
if (active === entry) {
|
||||
schedule(applyGeometry);
|
||||
return;
|
||||
}
|
||||
if (active) deactivate(active, false);
|
||||
active = entry;
|
||||
entry.scrollTop = entry.panel.scrollTop;
|
||||
entry.panel.classList?.add('composer-keyboard-active');
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
applyGeometry();
|
||||
}
|
||||
|
||||
function deactivate(entry, restore = true) {
|
||||
if (active !== entry) return;
|
||||
viewport?.removeEventListener('resize', onViewportChange);
|
||||
viewport?.removeEventListener('scroll', onViewportChange);
|
||||
entry.panel.classList?.remove('composer-keyboard-active');
|
||||
entry.panel.style.removeProperty('--composer-viewport-top');
|
||||
entry.panel.style.removeProperty('--composer-viewport-height');
|
||||
active = null;
|
||||
if (restore) schedule(() => { entry.panel.scrollTop = entry.scrollTop; });
|
||||
}
|
||||
|
||||
function bind(entry) {
|
||||
entry.onFocus = () => activate(entry);
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.composer.addEventListener('focus', entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.composer.removeEventListener('focus', entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
entries.forEach(bind);
|
||||
},
|
||||
close(panel) {
|
||||
const entry = entries.find(candidate => candidate.panel === panel);
|
||||
if (entry) deactivate(entry, false);
|
||||
},
|
||||
stop() {
|
||||
if (!started) return;
|
||||
if (active) deactivate(active, false);
|
||||
entries.forEach(unbind);
|
||||
started = false;
|
||||
},
|
||||
revealActive() {
|
||||
schedule(applyGeometry);
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function createDisclosure({ disclosure, launcher }) {
|
||||
function onKeydown(event) {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
event.preventDefault();
|
||||
disclosure.open = false;
|
||||
launcher.focus();
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
disclosure.addEventListener('keydown', onKeydown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { chooseFilter, createDisclosure };
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileSearchViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileSearchViewport(options) {
|
||||
const palette = options.palette;
|
||||
const results = options.results;
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = false;
|
||||
let scrollTop = 0;
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !mediaQuery.matches || !viewport) return;
|
||||
palette.style.setProperty('--search-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
palette.style.setProperty('--search-viewport-height', `${viewport.height}px`);
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
return {
|
||||
open() {
|
||||
if (active || !mediaQuery.matches || !viewport) return;
|
||||
active = true;
|
||||
applyGeometry();
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
},
|
||||
close() {
|
||||
if (active && viewport) {
|
||||
viewport.removeEventListener('resize', onViewportChange);
|
||||
viewport.removeEventListener('scroll', onViewportChange);
|
||||
}
|
||||
active = false;
|
||||
palette.style.removeProperty('--search-viewport-top');
|
||||
palette.style.removeProperty('--search-viewport-height');
|
||||
},
|
||||
rememberScroll() {
|
||||
scrollTop = results.scrollTop;
|
||||
},
|
||||
restoreScroll() {
|
||||
schedule(() => { results.scrollTop = scrollTop; });
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
let workMode = 'work';
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
@@ -34,9 +35,17 @@
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
function updateWork(mode, count) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
workMode = labels[mode] ? mode : 'work';
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
@@ -44,10 +53,14 @@
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention};
|
||||
function updateAttention(count) {
|
||||
updateWork(workMode, count);
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileWorkEntry = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
return 'work';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else options.openFallback();
|
||||
return current;
|
||||
}
|
||||
|
||||
return {mode, open};
|
||||
});
|
||||
+106
-14
@@ -280,6 +280,8 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
queueRead = null,
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
@@ -288,14 +290,16 @@ function createNotificationReader({
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function open(item) {
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = await load(item.notification_id);
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
@@ -324,7 +328,7 @@ function createNotificationReader({
|
||||
return true;
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected) return false;
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
const version = loadVersion;
|
||||
onStatus('Loading older messages…');
|
||||
@@ -342,20 +346,21 @@ function createNotificationReader({
|
||||
}
|
||||
},
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking) return false;
|
||||
if (!selected || marking || (offlineHydrated && !queueRead)) return false;
|
||||
const current = selected;
|
||||
const queueing = offlineHydrated;
|
||||
marking = true;
|
||||
onStatus('Marking update read…');
|
||||
onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
|
||||
try {
|
||||
await markRead(current.notification_id);
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item =>
|
||||
item && item.has_update && Number.isInteger(item.notification_id)
|
||||
);
|
||||
if (next) await open(next);
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
@@ -363,7 +368,7 @@ function createNotificationReader({
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
@@ -467,10 +472,67 @@ function workIdentity(item) {
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
|
||||
function createWorkSessionCheckpoint({
|
||||
storage,
|
||||
getLogin,
|
||||
onError = () => {},
|
||||
key = 'stackchain.today-session.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
let errorReported = false;
|
||||
const reportError = error => {
|
||||
if (errorReported) return;
|
||||
errorReported = true;
|
||||
onError(error);
|
||||
};
|
||||
const read = () => {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
|
||||
!Number.isInteger(value.index) || value.index < 0) return null;
|
||||
return value;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
read,
|
||||
save(identity, index) {
|
||||
const owner = login();
|
||||
if (!owner) return false;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
if (!read()) return false;
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -480,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
const clearCheckpoint = durable;
|
||||
running = false;
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -490,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -497,11 +563,36 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
start() {
|
||||
checkpointed: () => running && durable,
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
reopen() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (index < 0) return false;
|
||||
currentIndex = index;
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume() {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
return openAt(items, 0);
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||
},
|
||||
start(item = null) {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
reconcile() {
|
||||
if (!running) return false;
|
||||
@@ -606,6 +697,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
function createNotificationReadOutbox({
|
||||
storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
|
||||
}) {
|
||||
const storageKey = 'stackchain.notification-read-outbox.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item?.kind === 'notification-read' &&
|
||||
Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
coordinator?.notify('notification-read');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains available. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function itemId(ownerLogin, notificationId) {
|
||||
return 'notification-read:' + ownerLogin + ':' + notificationId;
|
||||
}
|
||||
|
||||
async function enqueueDurably(notificationId) {
|
||||
notificationId = Number(notificationId);
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
|
||||
const items = read();
|
||||
const id = itemId(ownerLogin, notificationId);
|
||||
let item = items.find(candidate => candidate.id === id);
|
||||
if (!item) {
|
||||
if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
|
||||
item = {
|
||||
id, kind: 'notification-read', notificationId, ownerLogin,
|
||||
status: 'queued', queuedAt: Number(now()),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, false);
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...item }, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'notification-read');
|
||||
await backgroundSync.requestSync();
|
||||
return { item: { ...item }, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item: { ...item }, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function suppress(items, login = getOwnerLogin()) {
|
||||
const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
|
||||
.map(item => item.notificationId));
|
||||
return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
return { confirmed: true };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
const confirmed = [];
|
||||
let items = read();
|
||||
for (const item of items) {
|
||||
if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
|
||||
try {
|
||||
const result = await send(item, currentLogin);
|
||||
if (result?.blocked || result?.busy) continue;
|
||||
if (result?.attention) {
|
||||
items = items.map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
|
||||
} : candidate);
|
||||
write(items);
|
||||
continue;
|
||||
}
|
||||
confirmed.push(item.notificationId);
|
||||
items = items.filter(candidate => candidate.id !== item.id);
|
||||
write(items);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
items = items.map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
|
||||
} : candidate);
|
||||
write(items);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
|
||||
.map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const state = states.get(item.id);
|
||||
if (state?.status === 'sent') return [];
|
||||
if (state?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;
|
||||
@@ -0,0 +1,83 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineToday = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
|
||||
|
||||
function createOfflineToday({
|
||||
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
|
||||
}) {
|
||||
let failed = new Set();
|
||||
let generation = 0;
|
||||
|
||||
function bounded(items) {
|
||||
return (Array.isArray(items) ? items : []).filter(item =>
|
||||
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
|
||||
).slice(0, Math.max(1, maxItems));
|
||||
}
|
||||
|
||||
async function run(login, sourceItems, onlyFailed) {
|
||||
login = String(login || '').trim();
|
||||
const items = bounded(sourceItems);
|
||||
const runGeneration = ++generation;
|
||||
if (!login || !items.length) {
|
||||
failed = new Set();
|
||||
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
|
||||
onStatus(empty);
|
||||
return empty;
|
||||
}
|
||||
|
||||
const previousFailed = failed;
|
||||
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
|
||||
const snapshot = pending => ({
|
||||
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||
});
|
||||
const candidates = items.filter(item => {
|
||||
const key = itemKey(item);
|
||||
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||
const saved = loadSavedDetail(login, item);
|
||||
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||
});
|
||||
failed = new Set();
|
||||
onStatus(snapshot(candidates.length));
|
||||
let cursor = 0;
|
||||
|
||||
async function worker() {
|
||||
while (cursor < candidates.length && runGeneration === generation) {
|
||||
const item = candidates[cursor++];
|
||||
try {
|
||||
const detail = await loadDetail(item);
|
||||
if (runGeneration !== generation) return;
|
||||
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved !== false) readyKeys.add(itemKey(item));
|
||||
} catch (_error) {
|
||||
if (runGeneration === generation) failed.add(itemKey(item));
|
||||
}
|
||||
if (runGeneration === generation) {
|
||||
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
|
||||
));
|
||||
if (runGeneration !== generation) return snapshot(0);
|
||||
const status = snapshot(0);
|
||||
onStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
return {
|
||||
warm: (login, items) => run(login, items, false),
|
||||
retry: (login, items) => run(login, items, true),
|
||||
cancel() { generation += 1; failed = new Set(); },
|
||||
failedKeys: () => Array.from(failed),
|
||||
};
|
||||
}
|
||||
|
||||
return createOfflineToday;
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
|
||||
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
|
||||
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
|
||||
const VERSION = 1;
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ITEM_FIELDS = [
|
||||
@@ -16,6 +17,15 @@
|
||||
const NOTIFICATION_FIELDS = [
|
||||
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
|
||||
];
|
||||
const DETAIL_FIELDS = [
|
||||
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
|
||||
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state',
|
||||
];
|
||||
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
|
||||
const REVIEW_FILE_FIELDS = [
|
||||
'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated',
|
||||
];
|
||||
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
@@ -25,18 +35,102 @@
|
||||
return output;
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({ storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS }) {
|
||||
function createOfflineWorkStore({
|
||||
storage, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS, maxDetails = 10,
|
||||
}) {
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
try { storage.removeItem(SNAPSHOT_KEY); }
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
}
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function detailKey(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = Number(item?.notification_id || 0);
|
||||
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = item?.kind === 'review' ? 'review' :
|
||||
item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number || 0);
|
||||
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
|
||||
}
|
||||
|
||||
function readDetails() {
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (_) {
|
||||
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDetail(login, item, detail) {
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!enabled() || !login || !key || !detail) return false;
|
||||
const conversation = detail.conversation || {};
|
||||
const data = {
|
||||
...pick(detail, DETAIL_FIELDS),
|
||||
conversation: {
|
||||
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
|
||||
page: Number(conversation.page || 1),
|
||||
older_page: conversation.older_page ?? null,
|
||||
total: Number(conversation.total || 0),
|
||||
},
|
||||
};
|
||||
if (item?.is_review || item?.kind === 'review') {
|
||||
data.files = (detail.files || []).slice(0, 50).map(file => ({
|
||||
...pick(file, REVIEW_FILE_FIELDS),
|
||||
diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)),
|
||||
}));
|
||||
data.reviews = (detail.reviews || []).slice(-20).map(review => ({
|
||||
...pick(review, REVIEW_FIELDS),
|
||||
user: pick(review?.user, ['login']),
|
||||
}));
|
||||
}
|
||||
const record = {
|
||||
key,
|
||||
user_login: login,
|
||||
saved_at: now().toISOString(),
|
||||
data,
|
||||
};
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
);
|
||||
records.push(record);
|
||||
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadDetail(login, item) {
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!login || !key) return null;
|
||||
const records = readDetails();
|
||||
const currentTime = now().getTime();
|
||||
const valid = records.filter(record => {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
|
||||
currentTime - savedAt <= maxAgeMs;
|
||||
});
|
||||
if (valid.length !== records.length) {
|
||||
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
|
||||
}
|
||||
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
|
||||
return record ? { ...record.data, saved_at: record.saved_at } : null;
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
try {
|
||||
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
|
||||
@@ -81,7 +175,7 @@
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, save, load, clear };
|
||||
return { enabled, setEnabled, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
|
||||
@@ -15,7 +15,7 @@ function createOutboxCoordinator({
|
||||
let sequence = 0;
|
||||
|
||||
function validChange(value) {
|
||||
return value && (value.queue === 'issue' || value.queue === 'authored') ? value : null;
|
||||
return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
|
||||
}
|
||||
|
||||
function publish(change) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createPlanTodayPreview = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createPlanTodayPreview({
|
||||
planner,
|
||||
identity,
|
||||
getScroll = () => 0,
|
||||
setScroll = () => {},
|
||||
onOpen = () => {},
|
||||
onClose = () => {},
|
||||
}) {
|
||||
let current = null;
|
||||
|
||||
function open(item, trigger = null) {
|
||||
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
|
||||
current = { item, trigger, scroll:Number(getScroll()) || 0 };
|
||||
onOpen(item, trigger);
|
||||
return true;
|
||||
}
|
||||
|
||||
function close({ add = false } = {}) {
|
||||
if (!current) return 'closed';
|
||||
const { item, trigger, scroll } = current;
|
||||
let result = 'returned';
|
||||
if (add) {
|
||||
const id = identity(item);
|
||||
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
|
||||
}
|
||||
current = null;
|
||||
setScroll(scroll);
|
||||
onClose(item, trigger);
|
||||
return result;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
|
||||
}
|
||||
|
||||
return { open, close, snapshot };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let openState = false;
|
||||
let draftIds = [];
|
||||
let itemsById = new Map();
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
for (const item of items || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && !unique.has(id)) unique.set(id, item);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
|
||||
}
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!openState) return 'closed';
|
||||
const id = identity?.(item);
|
||||
if (!id) return 'unavailable';
|
||||
itemsById.set(id, item);
|
||||
const index = draftIds.indexOf(id);
|
||||
if (index >= 0) {
|
||||
draftIds.splice(index, 1);
|
||||
return 'removed';
|
||||
}
|
||||
if (draftIds.length >= limit) return 'full';
|
||||
draftIds.push(id);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
function move(id, direction) {
|
||||
const index = draftIds.indexOf(id);
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
|
||||
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
openState = false;
|
||||
draftIds = [];
|
||||
itemsById = new Map();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
if (save?.(ids) === false) return 'unavailable';
|
||||
const first = ids.length ? itemsById.get(ids[0]) : null;
|
||||
close();
|
||||
if (startAfterSave && first) start?.(first);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
}
|
||||
|
||||
function item(id) {
|
||||
return itemsById.get(id) || null;
|
||||
}
|
||||
|
||||
function candidates() {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -0,0 +1,61 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
|
||||
return function queueToday(issue) {
|
||||
const added = todayWork.add(issue);
|
||||
if (added !== 'added' && added !== 'exists') return added;
|
||||
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
|
||||
refresh();
|
||||
warm();
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
return 'queued';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
|
||||
+144
-9
@@ -1,10 +1,14 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v23';
|
||||
const CACHE = 'stackchain-dashboard-shell-v67';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
@@ -13,56 +17,170 @@ const SHELL = [
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
async function sessionCsrf() {
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
const deadline = new Promise((resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Dashboard navigation timed out.'));
|
||||
}, NAVIGATION_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
fetch(request, { signal: controller.signal }),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionCsrf(signal) {
|
||||
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) return '';
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
|
||||
}
|
||||
|
||||
let batchedCsrf = null;
|
||||
let batchedCsrfController = null;
|
||||
async function withSessionCsrf(work) {
|
||||
batchedCsrfController = new AbortController();
|
||||
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
batchedCsrfController.abort();
|
||||
batchedCsrf = null;
|
||||
batchedCsrfController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
||||
}
|
||||
|
||||
async function storeOfflineLease(expiresAt) {
|
||||
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(OFFLINE_LEASE_URL, new Response(
|
||||
JSON.stringify({ expires_at: expiresAt }),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
||||
));
|
||||
}
|
||||
|
||||
async function validOfflineLease(cache) {
|
||||
const response = await cache.match(OFFLINE_LEASE_URL);
|
||||
const payload = await response?.json?.().catch(() => ({})) || {};
|
||||
return Number.isInteger(payload.expires_at)
|
||||
&& payload.expires_at > Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
|
||||
return new Response(
|
||||
'Your Stackchain session expired. Reconnect and sign in.',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
async function cachedShellWithValidLease(cache) {
|
||||
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
|
||||
return cache.match(BASE);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const csrf = await sessionCsrf();
|
||||
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
|
||||
if (batchedCsrf && options.signal) {
|
||||
if (options.signal.aborted) cancelBatchedCsrf();
|
||||
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
|
||||
}
|
||||
let csrf;
|
||||
try {
|
||||
csrf = await (batchedCsrf || sessionCsrf(options.signal));
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
|
||||
}
|
||||
if (options.signal?.aborted) throw new Error('Background request aborted.');
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetch(new URL(url, self.location.origin), requestOptions);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || payload.detail || 'Background issue delivery failed.');
|
||||
const code = payload.code || payload.detail?.code;
|
||||
if (response.status === 401 && code === 'session_revoked') {
|
||||
await purgeRevokedSessionData();
|
||||
}
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||
error.status = response.status;
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const issueSync = self.__issueSync || createBackgroundIssueSync({
|
||||
store: createIssueSyncStore(), fetchJson, base: BASE,
|
||||
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
|
||||
});
|
||||
|
||||
async function flushAndNotify() {
|
||||
@@ -99,6 +217,17 @@ self.addEventListener('sync', event => {
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
if (event.data?.type === 'stackchain-session-lease') {
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt));
|
||||
}
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
@@ -122,18 +251,24 @@ self.addEventListener('fetch', event => {
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).then(async response => {
|
||||
fetchNavigation(request).then(async response => {
|
||||
const cache = await caches.open(CACHE);
|
||||
if (response.ok) {
|
||||
const responseUrl = new URL(response.url || request.url);
|
||||
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
|
||||
if (response.ok && !response.redirected && isDashboardShell) {
|
||||
await cache.put(BASE, response.clone());
|
||||
} else if (OUTAGE_STATUSES.has(response.status)) {
|
||||
const cached = await cache.match(BASE);
|
||||
const cached = await cachedShellWithValidLease(cache);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
return cache.match(BASE);
|
||||
const cached = await cachedShellWithValidLease(cache);
|
||||
return cached || new Response(
|
||||
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
|
||||
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
|
||||
+289
-8
@@ -13,9 +13,17 @@
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
confirmAction: message => root.confirm(message),
|
||||
promptAuthorization: details => root.prompt(
|
||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||
),
|
||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||
onClearError: error => {
|
||||
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||||
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||||
},
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
@@ -23,17 +31,117 @@
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
boundary.resumeQueuedWork();
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
});
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
};
|
||||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location, confirmAction,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
requestTimeoutMs = 15000,
|
||||
now = () => Date.now(),
|
||||
setTimer = (callback, delay) => setTimeout(callback, delay),
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
location.replace(base + 'login?reason=session-revoked');
|
||||
}
|
||||
|
||||
async function handleSessionExpiry() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-expired') {
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
}
|
||||
|
||||
function storedOfflineLease() {
|
||||
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function scheduleOfflineExpiry(expiresAt) {
|
||||
const delay = Math.max(0, expiresAt * 1000 - now());
|
||||
setTimer(() => handleSessionExpiry(), delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt) {
|
||||
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
const entry = String(cookie?.() || '').split(';')
|
||||
@@ -47,7 +155,57 @@
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}) {
|
||||
function isDashboardApi(input) {
|
||||
try {
|
||||
const url = new URL(String(input?.url || input), origin);
|
||||
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
async function fetchWithDeadline(input, options, method, phase = 'request') {
|
||||
if (!isDashboardApi(input)) return fetchImpl(input, options);
|
||||
const controller = new AbortController();
|
||||
const callerSignal = options.signal || input?.signal;
|
||||
let timeout;
|
||||
let rejectCancellation;
|
||||
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
|
||||
const cancelFromCaller = () => {
|
||||
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
|
||||
error.source = 'caller';
|
||||
controller.abort(error);
|
||||
rejectCancellation(error);
|
||||
};
|
||||
if (callerSignal?.aborted) cancelFromCaller();
|
||||
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
|
||||
const deadline = new Promise((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
SAFE_METHODS.has(method)
|
||||
? 'Request timed out. Try again.'
|
||||
: 'Request timed out. Refresh to verify the outcome before retrying.'
|
||||
);
|
||||
error.name = 'TimeoutError';
|
||||
error.method = method;
|
||||
error.outcome = 'unknown';
|
||||
error.safeToRetry = SAFE_METHODS.has(method);
|
||||
error.phase = phase;
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, requestTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
|
||||
deadline,
|
||||
cancellation,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||
const requestOptions = { ...options };
|
||||
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
|
||||
@@ -56,11 +214,72 @@
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetchImpl(input, requestOptions);
|
||||
if (response.status === 401) onExpired();
|
||||
const response = await fetchWithDeadline(input, requestOptions, method);
|
||||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const grant = await authorization.json().catch(() => ({}));
|
||||
if (!grant.grant) return response;
|
||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||||
retryHeaders.set('X-Step-Up-Grant', grant.grant);
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
expirationStarted = true;
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else {
|
||||
expirationStarted = false;
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
|
||||
await publishOfflineLease(payload.expires_at);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt * 1000 > now()) {
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
return true;
|
||||
}
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeDashboardStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
@@ -73,17 +292,69 @@
|
||||
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateOutbox() {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function clearPrivateDeviceData() {
|
||||
removeDashboardStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
|
||||
catch (_error) { /* Continue clearing other dashboard state. */ }
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateOutbox();
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||||
}
|
||||
|
||||
async function listActiveDevices() {
|
||||
const response = await sessionFetch(base + 'api/v1/sessions');
|
||||
if (!response.ok) throw new Error('Could not load active devices');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||||
}
|
||||
|
||||
async function revokeActiveDevice(device) {
|
||||
if (!device?.management_id || device.current) return false;
|
||||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/sessions/' + encodeURIComponent(device.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not revoke active device');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
|
||||
@@ -110,5 +381,15 @@
|
||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, signOutAllDevices, clearPrivateDeviceData, resumeQueuedWork };
|
||||
return {
|
||||
fetch: sessionFetch,
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
revokeActiveDevice,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createTaskOverlayHistory = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
let started = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
},
|
||||
open(kind) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
return records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id))
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
)) {
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
for (const id of ids || []) enqueue('add', id);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/today', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Today delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+17
-3
@@ -48,6 +48,16 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return write(unique);
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
@@ -64,11 +74,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids);
|
||||
}
|
||||
|
||||
function reconcile(items, { pruneMissing = false } = {}) {
|
||||
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const ids = read();
|
||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||
if (retained.length !== ids.length) write(retained);
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) write(retained);
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
|
||||
@@ -85,7 +99,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, add, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,65 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
|
||||
}
|
||||
|
||||
function list() {
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) throw new Error('This capture is no longer available.');
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
@@ -0,0 +1,78 @@
|
||||
function createUpdateOwnership({ claim, addToday, onClaimed, onState }) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
let request = null;
|
||||
|
||||
function state(value) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
claimedItem = null;
|
||||
request = null;
|
||||
state({
|
||||
action: nextDetail?.issue?.claimable ? 'claim' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
if (request) return request;
|
||||
if (claimedItem) {
|
||||
const result = addToday(claimedItem);
|
||||
const messages = {
|
||||
added: 'Added to Today.',
|
||||
exists: 'This issue is already in Today.',
|
||||
full: 'Today is limited to 5 items. Remove one before adding more.',
|
||||
unavailable: 'Could not save Today on this device.',
|
||||
};
|
||||
state({ action: 'today', busy: false, message: messages[result] || messages.unavailable });
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
if (!detail?.issue?.claimable) return Promise.resolve('hidden');
|
||||
|
||||
state({ action: 'claim', busy: true, message: 'Assigning…' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({
|
||||
action: 'claim',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"commit": "8ff742c0fe0970d43339b8931054d63b1029591d",
|
||||
"buildDate": "2026-07-12T12:56:54Z",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "./.gitea/workflows/ci.yml",
|
||||
"type": "file",
|
||||
"bytes": 662,
|
||||
"sha256": "6ea3766610118ac34d6e9e8996eb13771ba6428ac8df6969f1150c0e719ed915"
|
||||
},\n {
|
||||
"path": "./.gitea/workflows/release.yml",
|
||||
"type": "file",
|
||||
"bytes": 884,
|
||||
"sha256": "647c8d8696fe77a04ab44a051816489a94d57caf33b8184a064fe8218193917c"
|
||||
},\n {
|
||||
"path": "./README.md",
|
||||
"type": "file",
|
||||
"bytes": 94,
|
||||
"sha256": "00230b3e64de86031df67093173ae57dd38490b6ca43ce188f046e59756d86e2"
|
||||
},\n {
|
||||
"path": "./creative-deliverables/widget-board-5.html",
|
||||
"type": "file",
|
||||
"bytes": 799,
|
||||
"sha256": "380da6f03fc3836ea2db2bbf15cd33a54991a48b67c22c6a4a02d58e1c82323a"
|
||||
},\n {
|
||||
"path": "./docs/ops-fundamentals.md",
|
||||
"type": "file",
|
||||
"bytes": 1123,
|
||||
"sha256": "1d4cb0620a1ae2576ba0eac7cd3dc45f74771cc69277a9b39c30a953a2aa81ac"
|
||||
},\n {
|
||||
"path": "./frontend/index.html",
|
||||
"type": "file",
|
||||
"bytes": 19183,
|
||||
"sha256": "5177c9fcf406ae13bd571f8d2679d075127af0eca0162f27a9021b209028304a"
|
||||
},\n {
|
||||
"path": "./frontend/static/index.html",
|
||||
"type": "file",
|
||||
"bytes": 15834,
|
||||
"sha256": "d4a930b6b6604093481c667a16c8bf55e720bbbab9c0f8f15dcb515b6e3fdba1"
|
||||
},\n {
|
||||
"path": "./scripts/gitea_sync.py",
|
||||
"type": "file",
|
||||
"bytes": 2987,
|
||||
"sha256": "15be3cf91384e4bc972d227d96868e92a3a0f50c97cd05fa85eb8f131377321b"
|
||||
},\n {
|
||||
"path": "./src/__init__.py",
|
||||
"type": "file",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},\n {
|
||||
"path": "./src/gitea_proxy.py",
|
||||
"type": "file",
|
||||
"bytes": 914,
|
||||
"sha256": "6af2ba285d5c070e9bbc95c81c5366d375c7d3293302a82bbf1812917e2191e4"
|
||||
},\n {
|
||||
"path": "./src/main.py",
|
||||
"type": "file",
|
||||
"bytes": 2771,
|
||||
"sha256": "9f0de0f0ca911744bc6ac1796c84de4c11bb7f709f69a672e228117b322319c7"
|
||||
},\n {
|
||||
"path": "./src/models.py",
|
||||
"type": "file",
|
||||
"bytes": 850,
|
||||
"sha256": "f0fdde5620006fb05cfe688607fe6815e424bf9a2b9798586a2c86cb7a62ba5a"
|
||||
},\n {
|
||||
"path": "./src/suggestion_engine.py",
|
||||
"type": "file",
|
||||
"bytes": 1774,
|
||||
"sha256": "531d0187cfa348995e45ceacc510ac557d5ae4c7ef3d58fa56dc4b39e08ca3f8"
|
||||
},\n {
|
||||
"path": "./src/views.py",
|
||||
"type": "file",
|
||||
"bytes": 219,
|
||||
"sha256": "b511ee830c4ae2c1564a81dfb52da9ded643b0612c3f46e0693fecc05d954b63"
|
||||
},\n {
|
||||
"path": "./tests/__init__.py",
|
||||
"type": "file",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},\n {
|
||||
"path": "./tests/test_suggestion_engine.py",
|
||||
"type": "file",
|
||||
"bytes": 810,
|
||||
"sha256": "d5c0ad9d51a9ec25556af190fe97a806bdfcee7d27e3f7666c584bbea2e3ef9c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||
RUNTIME_FILES = ("README.md", "requirements.txt")
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def runtime_files(root: Path) -> list[Path]:
|
||||
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
|
||||
for name in RUNTIME_DIRECTORIES:
|
||||
directory = root / name
|
||||
if directory.is_dir():
|
||||
files.extend(path for path in directory.rglob("*") if path.is_file())
|
||||
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
||||
|
||||
|
||||
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"files": {
|
||||
path.relative_to(root).as_posix(): {
|
||||
"sha256": sha256(path.read_bytes()),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
for path in files
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def json_bytes(payload: dict) -> bytes:
|
||||
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarfile.TarInfo:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = size
|
||||
info.mtime = epoch
|
||||
info.mode = 0o755 if executable else 0o644
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
return info
|
||||
|
||||
|
||||
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
|
||||
root = root.resolve()
|
||||
files = runtime_files(root)
|
||||
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
|
||||
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
|
||||
raise ValueError("release root is missing required runtime files")
|
||||
|
||||
embedded_manifest = manifest_for(root, files, commit)
|
||||
archive_members: dict[str, tuple[bytes, bool]] = {
|
||||
path.relative_to(root).as_posix(): (
|
||||
path.read_bytes(),
|
||||
bool(path.stat().st_mode & 0o111),
|
||||
)
|
||||
for path in files
|
||||
}
|
||||
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"stackchain-dashboard-{commit[:12]}"
|
||||
archive_path = output_dir / f"{stem}.tar.gz"
|
||||
with archive_path.open("wb") as raw:
|
||||
with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=epoch) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for name in sorted(archive_members):
|
||||
data, executable = archive_members[name]
|
||||
archive.addfile(tar_info(name, len(data), epoch, executable), io.BytesIO(data))
|
||||
|
||||
archive_data = archive_path.read_bytes()
|
||||
digest = sha256(archive_data)
|
||||
public_manifest = {
|
||||
**embedded_manifest,
|
||||
"artifact": {
|
||||
"name": archive_path.name,
|
||||
"sha256": digest,
|
||||
"size": len(archive_data),
|
||||
},
|
||||
}
|
||||
manifest_path = output_dir / f"{stem}.manifest.json"
|
||||
checksum_path = output_dir / f"{stem}.sha256"
|
||||
manifest_path.write_bytes(json_bytes(public_manifest))
|
||||
checksum_path.write_text(f"{digest} {archive_path.name}\n")
|
||||
return archive_path, manifest_path, checksum_path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--source-date-epoch", type=int, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a Stackchain Dashboard release bundle before promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
def one(directory: Path, pattern: str) -> Path:
|
||||
matches = sorted(directory.glob(pattern))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one {pattern} file, found {len(matches)}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def verify(input_dir: Path, commit: str) -> None:
|
||||
archive_path = one(input_dir, "*.tar.gz")
|
||||
manifest_path = one(input_dir, "*.manifest.json")
|
||||
checksum_path = one(input_dir, "*.sha256")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
|
||||
if manifest.get("commit") != commit:
|
||||
raise ValueError("manifest commit does not match the tested commit")
|
||||
artifact = manifest.get("artifact", {})
|
||||
archive_data = archive_path.read_bytes()
|
||||
archive_digest = digest(archive_data)
|
||||
if artifact.get("name") != archive_path.name:
|
||||
raise ValueError("manifest artifact name does not match the bundle")
|
||||
if artifact.get("size") != len(archive_data) or artifact.get("sha256") != archive_digest:
|
||||
raise ValueError("bundle does not match its manifest")
|
||||
|
||||
checksum_parts = checksum_path.read_text().strip().split()
|
||||
if checksum_parts != [archive_digest, archive_path.name]:
|
||||
raise ValueError("checksum file does not match the bundle")
|
||||
|
||||
expected_files = manifest.get("files")
|
||||
if not isinstance(expected_files, dict):
|
||||
raise ValueError("manifest files must be an object")
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not member.isfile():
|
||||
raise ValueError("bundle contains an unsafe member")
|
||||
names = {member.name for member in members}
|
||||
if names != set(expected_files) | {"release-manifest.json"}:
|
||||
raise ValueError("bundle contents do not match the manifest")
|
||||
embedded_file = archive.extractfile("release-manifest.json")
|
||||
if embedded_file is None:
|
||||
raise ValueError("bundle manifest is missing")
|
||||
embedded = json.load(embedded_file)
|
||||
if embedded.get("commit") != commit or embedded.get("files") != expected_files:
|
||||
raise ValueError("embedded manifest does not match the public manifest")
|
||||
for name, metadata in expected_files.items():
|
||||
bundled = archive.extractfile(name)
|
||||
if bundled is None:
|
||||
raise ValueError(f"bundle member is missing: {name}")
|
||||
data = bundled.read()
|
||||
if metadata != {"sha256": digest(data), "size": len(data)}:
|
||||
raise ValueError(f"bundle member failed verification: {name}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input-dir", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
try:
|
||||
verify(args.input_dir, args.commit)
|
||||
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
|
||||
print(f"release verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
print(f"verified release bundle for {args.commit}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.middleware.gzip import GZipResponder, IdentityResponder
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
def _quality(accept_encoding: str, coding: str) -> float:
|
||||
qualities: dict[str, float] = {}
|
||||
for entry in accept_encoding.split(","):
|
||||
parts = [part.strip() for part in entry.split(";")]
|
||||
name = parts[0].lower()
|
||||
if not name:
|
||||
continue
|
||||
quality = 1.0
|
||||
for parameter in parts[1:]:
|
||||
key, separator, value = parameter.partition("=")
|
||||
if separator and key.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
qualities[name] = quality
|
||||
return qualities.get(coding, qualities.get("*", 0.0))
|
||||
|
||||
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
if _quality(accepted, "gzip") > 0:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
else:
|
||||
responder = IdentityResponder(self.app, self.minimum_size)
|
||||
await responder(scope, receive, send)
|
||||
+63
-8
@@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
STEP_UP_TTL_SECONDS = 90
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
INSECURE_LOCAL_MODE = "insecure-local"
|
||||
@@ -30,6 +31,12 @@ class Session:
|
||||
expires_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionVerification:
|
||||
session: Session | None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def access_token() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||
|
||||
@@ -89,7 +96,9 @@ def _session_store(now: int | None = None) -> SessionStore:
|
||||
return SessionStore(database, clock=current)
|
||||
|
||||
|
||||
def issue_session(now: int | None = None) -> tuple[str, Session]:
|
||||
def issue_session(
|
||||
now: int | None = None, *, device_label: str = "This device"
|
||||
) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
session = Session(
|
||||
@@ -104,17 +113,21 @@ def issue_session(now: int | None = None) -> tuple[str, Session]:
|
||||
).encode()
|
||||
encoded = _encode(payload)
|
||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
_session_store(now).activate(session.session_id, session.expires_at)
|
||||
_session_store(now).activate(
|
||||
session.session_id, session.expires_at, device_label=device_label
|
||||
)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
def verify_session_with_reason(
|
||||
value: str | None, now: int | None = None
|
||||
) -> SessionVerification:
|
||||
if not value or "." not in value or not enabled():
|
||||
return None
|
||||
return SessionVerification(None)
|
||||
encoded, supplied_signature = value.rsplit(".", 1)
|
||||
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
if not hmac.compare_digest(supplied_signature, expected):
|
||||
return None
|
||||
return SessionVerification(None)
|
||||
try:
|
||||
payload = json.loads(_decode(encoded))
|
||||
session = Session(
|
||||
@@ -123,7 +136,7 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
expires_at=int(payload["exp"]),
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return None
|
||||
return SessionVerification(None)
|
||||
current = int(time.time() if now is None else now)
|
||||
if (
|
||||
session.expires_at <= current
|
||||
@@ -132,8 +145,14 @@ def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
or not isinstance(session.csrf, str)
|
||||
or not session.csrf
|
||||
):
|
||||
return None
|
||||
return session if _session_store(now).is_active(session.session_id, session.expires_at) else None
|
||||
return SessionVerification(None)
|
||||
if not _session_store(now).is_active(session.session_id, session.expires_at):
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(session)
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
return verify_session_with_reason(value, now).session
|
||||
|
||||
|
||||
def revoke_session(session: Session) -> None:
|
||||
@@ -144,10 +163,46 @@ async def revoke_all_sessions() -> None:
|
||||
await asyncio.to_thread(_session_store().revoke_all)
|
||||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def revoke_managed_session(management_id: str) -> bool:
|
||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||
|
||||
|
||||
async def issue_step_up(session: Session, *, action: str, target: str) -> str:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().mint_step_up,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
ttl_seconds=STEP_UP_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
async def consume_step_up(
|
||||
grant: str, session: Session, *, action: str, target: str
|
||||
) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().consume_step_up,
|
||||
grant,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
)
|
||||
|
||||
|
||||
async def request_session(request: Request) -> Session | None:
|
||||
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
async def request_session_verification(request: Request) -> SessionVerification:
|
||||
return await asyncio.to_thread(
|
||||
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
|
||||
)
|
||||
|
||||
|
||||
def cookie_path(request: Request) -> str:
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
return root_path or "/"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Build the content-addressed browser shell served by the dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
runtime_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
page_sources: tuple[str, ...]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
chunks = []
|
||||
for source in sources:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||
source_html = (frontend_dir / "index.html").read_text()
|
||||
sources = tuple(SCRIPT_TAG.findall(source_html))
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
runtime = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(runtime).hexdigest()[:16]
|
||||
runtime_name = f"runtime-{digest}.js"
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>", f'<script src="{runtime_name}"></script>\n</body>'
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n",
|
||||
)
|
||||
worker = re.sub(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-v\d+';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{digest}';",
|
||||
worker,
|
||||
count=1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=runtime,
|
||||
runtime_digest=digest,
|
||||
runtime_name=runtime_name,
|
||||
page_sources=sources,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
+66
-2
@@ -133,6 +133,14 @@ class WorkItems(list[dict]):
|
||||
self.pagination = pagination
|
||||
|
||||
|
||||
class RepositoryItems(list[dict]):
|
||||
"""A list-compatible repository page carrying truthful pagination metadata."""
|
||||
|
||||
def __init__(self, items: list[dict], pagination: dict):
|
||||
super().__init__(items)
|
||||
self.pagination = pagination
|
||||
|
||||
|
||||
class WorkRouteUnavailableError(ValueError):
|
||||
"""Raised when a shared route no longer belongs in the current user's queue."""
|
||||
|
||||
@@ -261,8 +269,48 @@ async def current_user() -> dict:
|
||||
return await fetch("user")
|
||||
|
||||
|
||||
async def repos() -> list[dict]:
|
||||
return await fetch("user/repos?limit=50")
|
||||
async def repo_page(page: int = 1, limit: int = 50) -> dict:
|
||||
"""Load one bounded page of repositories available to the current user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/user/repos",
|
||||
headers=_auth(),
|
||||
params={"page": page, "limit": limit},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea repository response was not a list")
|
||||
items = [item for item in payload if isinstance(item, dict)]
|
||||
try:
|
||||
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
|
||||
except (TypeError, ValueError):
|
||||
total = len(items)
|
||||
return {
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
}
|
||||
|
||||
|
||||
async def repos() -> RepositoryItems:
|
||||
result = await repo_page()
|
||||
return RepositoryItems(
|
||||
result["items"],
|
||||
{key: result[key] for key in ("page", "total", "has_more")},
|
||||
)
|
||||
|
||||
|
||||
async def repository_access(repository: str) -> dict | None:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}", headers=_auth()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
WORK_SEARCHES = {
|
||||
@@ -680,6 +728,21 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
|
||||
subject.get("latest_comment_html_url")
|
||||
)
|
||||
assignee_values = subject_detail.get("assignees")
|
||||
assignees = [
|
||||
value.get("login")
|
||||
for value in (assignee_values if isinstance(assignee_values, list) else [])
|
||||
if isinstance(value, dict) and isinstance(value.get("login"), str)
|
||||
]
|
||||
issue = {
|
||||
"number": subject_detail.get("number"),
|
||||
"assignees": assignees,
|
||||
"claimable": (
|
||||
subject.get("type") == "Issue"
|
||||
and subject_detail.get("state", subject.get("state")) == "open"
|
||||
and not assignees
|
||||
),
|
||||
} if supported_conversation and subject.get("type") == "Issue" else None
|
||||
return {
|
||||
"id": thread_id,
|
||||
"repository": repository.get("full_name", "")
|
||||
@@ -710,6 +773,7 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
else "",
|
||||
"url": latest_url,
|
||||
},
|
||||
"issue": issue,
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -78,7 +78,7 @@ class IdempotencyLedger:
|
||||
(now - self.ttl_seconds,),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT fingerprint, status, response_json FROM idempotency_operations "
|
||||
"SELECT fingerprint, status, response_json, created_at FROM idempotency_operations "
|
||||
"WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
@@ -87,9 +87,13 @@ class IdempotencyLedger:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
if row[3] <= now - self.ttl_seconds:
|
||||
return Reservation("uncertain")
|
||||
return Reservation("pending")
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM idempotency_operations"
|
||||
"SELECT COUNT(*) FROM idempotency_operations "
|
||||
"WHERE status = 'completed' OR created_at > ?",
|
||||
(now - self.ttl_seconds,),
|
||||
).fetchone()[0]
|
||||
if count >= self.max_entries:
|
||||
completed = connection.execute(
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Durable, account-scoped Later deferrals."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LaterStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
records TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_item_revisions (
|
||||
login TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
PRIMARY KEY (login, item_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
if "created_at" not in columns:
|
||||
connection.execute("ALTER TABLE later_operations ADD COLUMN created_at REAL")
|
||||
connection.execute(
|
||||
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
(login, operation_id, now),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM later_operations WHERE login = ? AND created_at < ?",
|
||||
(login, now - self.operation_retention_seconds),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM later_operations WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM later_operations WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.operation_limit),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "records": {}}
|
||||
return {"revision": int(row[0]), "records": json.loads(row[1])}
|
||||
|
||||
@staticmethod
|
||||
def _validate_wake_at(wake_at: str | None) -> str:
|
||||
if not wake_at:
|
||||
raise ValueError("wake_at is required for defer")
|
||||
try:
|
||||
datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("wake_at must be an ISO timestamp") from error
|
||||
return wake_at
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
operation_id: str,
|
||||
action: str,
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
result = self.apply_batch(login, [{
|
||||
"operation_id": operation_id,
|
||||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply ordered deferrals with one SQLite write transaction."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
records = dict(snapshot["records"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
|
||||
# Existing databases predate per-item revisions. Conservatively mark
|
||||
# active deferrals as changed at the latest known plan revision.
|
||||
for item_id in records:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?)",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
item_revisions = dict(connection.execute(
|
||||
"SELECT item_id, revision FROM later_item_revisions WHERE login = ?",
|
||||
(login,),
|
||||
).fetchall())
|
||||
batch_start_item_revisions = dict(item_revisions)
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
action = operation.get("action", "")
|
||||
item_id = operation.get("item_id", "")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"defer", "restore"}:
|
||||
raise ValueError("unsupported Later action")
|
||||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
base_revision = operation.get("base_revision")
|
||||
if base_revision is None:
|
||||
base_revision = snapshot["revision"]
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
if batch_start_item_revisions.get(item_id, 0) > base_revision:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({
|
||||
"operation_id": operation_id,
|
||||
"reason": "stale_intent",
|
||||
})
|
||||
continue
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
revision += 1 if changed else 0
|
||||
if changed:
|
||||
item_revisions[item_id] = revision
|
||||
connection.execute(
|
||||
"INSERT INTO later_item_revisions(login, item_id, revision) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(login, item_id) DO UPDATE SET revision = excluded.revision",
|
||||
(login, item_id, revision),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"records": records,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
+528
-45
@@ -2,19 +2,24 @@ import asyncio
|
||||
import hmac
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from collections.abc import Awaitable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||
|
||||
from src import dashboard_auth, gitea_proxy
|
||||
from src.compression import NegotiatedGZipMiddleware
|
||||
from src.gitea_proxy import (
|
||||
activity_events,
|
||||
current_user,
|
||||
@@ -30,8 +35,11 @@ from src.gitea_proxy import (
|
||||
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
|
||||
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.suggestion_engine import compute
|
||||
from src.views import router as frontend_router
|
||||
from src.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
@@ -59,6 +67,8 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
|
||||
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
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
@@ -101,6 +111,7 @@ _live_snapshot_refreshing_sections: set[str] = set()
|
||||
_live_section_revisions: dict[str, int] = {
|
||||
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_live_revision_generation = secrets.token_hex(8)
|
||||
_read_notification_ids: set[int] = set()
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
@@ -131,6 +142,23 @@ class ReadinessPayloadError(ValueError):
|
||||
|
||||
class DashboardSignIn(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
device_label: str = Field(default="This device", min_length=1, max_length=64)
|
||||
|
||||
@field_validator("device_label")
|
||||
@classmethod
|
||||
def normalize_device_label(cls, value: str) -> str:
|
||||
normalized = " ".join(value.split())
|
||||
if not normalized:
|
||||
raise ValueError("device label cannot be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class FreshAuthorization(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=1_024)
|
||||
action: Literal[
|
||||
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
|
||||
]
|
||||
target: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
def _login_attempt_store() -> LoginAttemptStore:
|
||||
@@ -147,10 +175,69 @@ def _login_attempt_store() -> LoginAttemptStore:
|
||||
)
|
||||
|
||||
|
||||
async def _require_step_up(
|
||||
request: Request,
|
||||
grant: str | None,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
) -> None:
|
||||
if dashboard_auth.mode() != dashboard_auth.OPERATOR_MODE:
|
||||
return
|
||||
try:
|
||||
valid = bool(grant) and await dashboard_auth.consume_step_up(
|
||||
grant, request.state.dashboard_session, action=action, target=target
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Session registry is temporarily unavailable"
|
||||
)
|
||||
if not valid:
|
||||
return_payload = {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": action,
|
||||
"target": target,
|
||||
}
|
||||
raise HTTPException(status_code=428, detail=return_payload)
|
||||
|
||||
|
||||
class NotificationReadBatch(BaseModel):
|
||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class TodayOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["add", "remove", "move"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_move_direction(self):
|
||||
if self.action == "move" and self.direction is None:
|
||||
raise ValueError("move requires a direction")
|
||||
if self.action != "move" and self.direction is not None:
|
||||
raise ValueError("direction is only valid for move")
|
||||
return self
|
||||
|
||||
|
||||
class LaterOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TodayOperationBatch(BaseModel):
|
||||
operations: list[TodayOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class LaterOperationBatch(BaseModel):
|
||||
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class NotificationReply(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
|
||||
@@ -313,9 +400,22 @@ async def _run_idempotent_authored_action(
|
||||
detail="Authored action queue is busy; please retry",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
existing = _authored_action_operations.get(idempotency_key)
|
||||
if reservation.state == "pending":
|
||||
if reservation.state == "uncertain" and (
|
||||
existing is None or existing[0] != fingerprint
|
||||
):
|
||||
operation.close()
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "delivery_uncertain",
|
||||
"message": (
|
||||
"Delivery could not be confirmed. Verify it was not posted before retrying."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if reservation.state in {"pending", "uncertain"}:
|
||||
operation.close()
|
||||
if existing is None or existing[0] != fingerprint:
|
||||
raise HTTPException(
|
||||
@@ -404,6 +504,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
||||
repository_pagination = getattr(repo_data, "pagination", None)
|
||||
if isinstance(repository_pagination, dict):
|
||||
payload["repository_pagination"] = repository_pagination
|
||||
pagination = {}
|
||||
pagination.update(getattr(issues_data, "pagination", {}))
|
||||
pagination.update(getattr(prs_data, "pagination", {}))
|
||||
@@ -452,6 +555,24 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||
app.include_router(frontend_router)
|
||||
|
||||
|
||||
def _share_target_login_redirect(request: Request) -> str:
|
||||
limits = {"title": 200, "text": 8000, "url": 2048}
|
||||
if any(
|
||||
len(request.query_params.get(name, "")) > limit
|
||||
for name, limit in limits.items()
|
||||
):
|
||||
return "login"
|
||||
shared = [
|
||||
(name, request.query_params[name])
|
||||
for name in limits
|
||||
if request.query_params.get(name)
|
||||
]
|
||||
if dashboard_auth.application_path(request) != "/" or not shared:
|
||||
return "login"
|
||||
continuation = f"./?{urlencode(shared)}"
|
||||
return f"login?{urlencode({'continue': continuation})}"
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_operator_session(request: Request, call_next):
|
||||
path = dashboard_auth.application_path(request)
|
||||
@@ -475,14 +596,23 @@ async def require_operator_session(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
public = (
|
||||
path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"}
|
||||
path in {
|
||||
"/healthz",
|
||||
"/readyz",
|
||||
"/login",
|
||||
"/manifest.webmanifest",
|
||||
"/" + FRONTEND_BUILD.runtime_name,
|
||||
}
|
||||
or path.startswith("/static/")
|
||||
or (path == "/api/v1/session" and request.method == "POST")
|
||||
)
|
||||
session = None
|
||||
session_reason = None
|
||||
if not public:
|
||||
try:
|
||||
session = await dashboard_auth.request_session(request)
|
||||
verification = await dashboard_auth.request_session_verification(request)
|
||||
session = verification.session
|
||||
session_reason = verification.reason
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
@@ -491,12 +621,24 @@ async def require_operator_session(request: Request, call_next):
|
||||
)
|
||||
if not public and session is None:
|
||||
if path.startswith("/api/"):
|
||||
payload = {"detail": "Authentication required"}
|
||||
if session_reason == "session_revoked":
|
||||
payload["code"] = session_reason
|
||||
return JSONResponse(
|
||||
{"detail": "Authentication required"},
|
||||
payload,
|
||||
status_code=401,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"})
|
||||
login_redirect = (
|
||||
"login?reason=session-revoked"
|
||||
if session_reason == "session_revoked"
|
||||
else _share_target_login_redirect(request)
|
||||
)
|
||||
return RedirectResponse(
|
||||
login_redirect,
|
||||
status_code=303,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
if not public and request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
supplied_csrf = request.headers.get("x-csrf-token", "")
|
||||
@@ -522,7 +664,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"} 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/later"} or path.startswith("/api/v1/work/") or (
|
||||
path.startswith("/api/v1/repos/")
|
||||
and path.endswith("/review")
|
||||
) or path.startswith("/api/v1/notifications") or (
|
||||
@@ -539,6 +681,53 @@ async def prevent_live_api_caching(request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
CONTENT_SECURITY_POLICY = "; ".join(
|
||||
(
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def redact_request_validation_error(
|
||||
_request: Request, error: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Return useful validation locations without reflecting submitted values."""
|
||||
details = [
|
||||
{key: item[key] for key in ("type", "loc", "msg") if key in item}
|
||||
for item in error.errors()
|
||||
]
|
||||
return JSONResponse(
|
||||
{"detail": details},
|
||||
status_code=422,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def enforce_browser_security_boundary(request: Request, call_next):
|
||||
"""Apply one browser trust boundary, including to auth short-circuits."""
|
||||
response = await call_next(request)
|
||||
response.headers["Content-Security-Policy"] = CONTENT_SECURITY_POLICY
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def health() -> dict[str, str]:
|
||||
"""Return process liveness without depending on Gitea."""
|
||||
@@ -555,7 +744,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = attempts.retry_after(source)
|
||||
retry_after = await asyncio.to_thread(attempts.retry_after, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
@@ -576,7 +765,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
payload.access_token, configured_token
|
||||
):
|
||||
try:
|
||||
attempts.record_failure(source)
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
@@ -585,7 +774,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||
try:
|
||||
attempts.clear(source)
|
||||
await asyncio.to_thread(attempts.clear, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
@@ -593,7 +782,9 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
try:
|
||||
signed, session = dashboard_auth.issue_session()
|
||||
signed, session = await asyncio.to_thread(
|
||||
dashboard_auth.issue_session, device_label=payload.device_label
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
@@ -624,20 +815,195 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
return {"authenticated": True}
|
||||
|
||||
|
||||
@app.post("/api/v1/fresh-authorization", status_code=201)
|
||||
async def fresh_authorization(payload: FreshAuthorization, request: Request):
|
||||
peer_host = request.client.host if request.client is not None else "unknown"
|
||||
source = client_source(
|
||||
peer_host,
|
||||
request.headers.get("x-forwarded-for", ""),
|
||||
os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""),
|
||||
)
|
||||
attempts = _login_attempt_store()
|
||||
try:
|
||||
retry_after = await asyncio.to_thread(attempts.retry_after, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
return JSONResponse(
|
||||
{"detail": "Too many sign-in attempts"},
|
||||
status_code=429,
|
||||
headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)},
|
||||
)
|
||||
configured_token = dashboard_auth.access_token()
|
||||
if not configured_token or not hmac.compare_digest(
|
||||
payload.access_token, configured_token
|
||||
):
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_failure, source)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid access token")
|
||||
try:
|
||||
await asyncio.to_thread(attempts.clear, source)
|
||||
grant = await dashboard_auth.issue_step_up(
|
||||
request.state.dashboard_session,
|
||||
action=payload.action,
|
||||
target=payload.target,
|
||||
)
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS},
|
||||
status_code=201,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/session")
|
||||
async def session_status(request: Request):
|
||||
session = request.state.dashboard_session
|
||||
return {
|
||||
payload = {
|
||||
"authenticated": session is not None,
|
||||
"csrf_token": session.csrf if session is not None else "",
|
||||
}
|
||||
if session is not None:
|
||||
payload["expires_at"] = session.expires_at
|
||||
return payload
|
||||
|
||||
|
||||
def _today_store() -> TodayStore:
|
||||
return TodayStore(
|
||||
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
|
||||
)
|
||||
|
||||
|
||||
def _later_store() -> LaterStore:
|
||||
return LaterStore(
|
||||
os.getenv("STACKCHAIN_LATER_DB", str(_state_dir / "later.sqlite3"))
|
||||
)
|
||||
|
||||
|
||||
async def _confirmed_login() -> str:
|
||||
try:
|
||||
user = await asyncio.wait_for(
|
||||
current_user(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Operator identity is unavailable"
|
||||
) from exc
|
||||
login = user.get("login", "") if isinstance(user, dict) else ""
|
||||
if not isinstance(login, str) or not login.strip():
|
||||
raise HTTPException(status_code=503, detail="Operator identity is unavailable")
|
||||
return login.strip().lower()
|
||||
|
||||
|
||||
@app.get("/api/v1/today")
|
||||
async def get_today_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_today_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/today")
|
||||
async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
if isinstance(payload, TodayOperationBatch):
|
||||
return await asyncio.to_thread(
|
||||
_today_store().apply_batch,
|
||||
login,
|
||||
[operation.model_dump() for operation in payload.operations],
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
_today_store().apply,
|
||||
login,
|
||||
payload.operation_id,
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
direction=payload.direction,
|
||||
)
|
||||
except TodayPlanFull:
|
||||
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Today synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/later")
|
||||
async def get_later_plan():
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
return await asyncio.to_thread(_later_store().get, login)
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Later synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/later")
|
||||
async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
if isinstance(payload, LaterOperationBatch):
|
||||
return await asyncio.to_thread(
|
||||
_later_store().apply_batch,
|
||||
login,
|
||||
[operation.model_dump() for operation in payload.operations],
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
_later_store().apply,
|
||||
login,
|
||||
payload.operation_id,
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=422, detail=str(error))
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Later synchronization is unavailable",
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/v1/session")
|
||||
async def sign_out(request: Request, response: Response):
|
||||
session = request.state.dashboard_session
|
||||
try:
|
||||
dashboard_auth.revoke_session(session)
|
||||
await asyncio.to_thread(dashboard_auth.revoke_session, session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
@@ -663,8 +1029,78 @@ async def sign_out(request: Request, response: Response):
|
||||
return {"authenticated": False, "clear_private_device_data": True}
|
||||
|
||||
|
||||
@app.get("/api/v1/sessions")
|
||||
async def list_active_devices(request: Request, response: Response):
|
||||
try:
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {
|
||||
"devices": [
|
||||
{
|
||||
"management_id": device.management_id,
|
||||
"device_label": device.device_label,
|
||||
"created_at": device.created_at,
|
||||
"expires_at": device.expires_at,
|
||||
"current": device.current,
|
||||
}
|
||||
for device in devices
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/v1/sessions/{management_id}")
|
||||
async def revoke_active_device(
|
||||
request: Request,
|
||||
management_id: str = PathParam(
|
||||
min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"
|
||||
),
|
||||
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="revoke_device",
|
||||
target=management_id,
|
||||
)
|
||||
try:
|
||||
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
|
||||
target = next(
|
||||
(device for device in devices if device.management_id == management_id), None
|
||||
)
|
||||
revoked = await dashboard_auth.revoke_managed_session(management_id)
|
||||
except dashboard_auth.SessionStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if target is None or not revoked:
|
||||
raise HTTPException(status_code=404, detail="Active device not found")
|
||||
return {"revoked": True, "current_session": target.current}
|
||||
|
||||
|
||||
@app.delete("/api/v1/sessions")
|
||||
async def sign_out_all_devices(request: Request, response: Response):
|
||||
async def sign_out_all_devices(
|
||||
request: Request,
|
||||
response: Response,
|
||||
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="revoke_all_sessions",
|
||||
target="all",
|
||||
)
|
||||
try:
|
||||
await dashboard_auth.revoke_all_sessions()
|
||||
except dashboard_auth.SessionStoreError:
|
||||
@@ -782,6 +1218,36 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories")
|
||||
async def repository_page(
|
||||
page: int = Query(default=1, ge=1, le=1000),
|
||||
limit: int = Query(default=50, ge=1, le=50),
|
||||
) -> JSONResponse:
|
||||
"""Return one bounded repository page for lazy issue-capture selection."""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.repo_page(page=page, limit=limit),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
items = [
|
||||
Repo(
|
||||
id=item["id"], name=item["name"], full_name=item["full_name"],
|
||||
description=item.get("description") or "", url=item["html_url"],
|
||||
updated_at=item.get("updated_at", ""),
|
||||
).model_dump()
|
||||
for item in result.get("items", [])
|
||||
if isinstance(item, dict)
|
||||
and all(field in item for field in ("id", "name", "full_name", "html_url"))
|
||||
]
|
||||
return JSONResponse({**result, "items": items})
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Repositories could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/background-identity")
|
||||
async def background_identity() -> JSONResponse:
|
||||
"""Return only the account key required to safely drain a browser outbox."""
|
||||
@@ -1158,7 +1624,7 @@ def _live_snapshot_payload(
|
||||
*,
|
||||
stale: bool,
|
||||
revalidating: bool,
|
||||
known_revisions: dict[str, int | None] | None = None,
|
||||
known_revisions: dict[str, str | None] | None = None,
|
||||
) -> dict:
|
||||
payload = dict(value)
|
||||
now = time.monotonic()
|
||||
@@ -1191,9 +1657,13 @@ def _live_snapshot_payload(
|
||||
"retry_in_seconds": min(retries, default=0),
|
||||
"sections": section_freshness,
|
||||
}
|
||||
payload["revisions"] = dict(_live_section_revisions)
|
||||
revision_tokens = {
|
||||
section: f"{_live_revision_generation}.{revision}"
|
||||
for section, revision in _live_section_revisions.items()
|
||||
}
|
||||
payload["revisions"] = revision_tokens
|
||||
for section, known_revision in (known_revisions or {}).items():
|
||||
if known_revision is None or known_revision != _live_section_revisions[section]:
|
||||
if known_revision is None or known_revision != revision_tokens[section]:
|
||||
continue
|
||||
payload.pop(section, None)
|
||||
if section == "notifications":
|
||||
@@ -1243,9 +1713,15 @@ def _without_read_notifications(snapshot: dict) -> dict:
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot(
|
||||
context_revision: int | None = Query(default=None, ge=0),
|
||||
events_revision: int | None = Query(default=None, ge=0),
|
||||
notifications_revision: int | None = Query(default=None, ge=0),
|
||||
context_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
events_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
notifications_revision: str | None = Query(
|
||||
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
|
||||
),
|
||||
) -> JSONResponse:
|
||||
"""Return a freshness-bounded snapshot and share identical upstream loads."""
|
||||
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
|
||||
@@ -1520,7 +1996,7 @@ async def claim_available_issue(
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "This issue was already claimed or is no longer open. Refresh Find Work."},
|
||||
{"error": "This issue was already claimed or is no longer open."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
@@ -1750,13 +2226,7 @@ async def repository_milestones(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_milestones():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_milestones(repository)
|
||||
|
||||
@@ -1779,13 +2249,7 @@ async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_labels():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_labels(repository)
|
||||
|
||||
@@ -1811,16 +2275,11 @@ async def create_assigned_issue(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def create_issue():
|
||||
user, available = await asyncio.gather(
|
||||
gitea_proxy.current_user(), gitea_proxy.repos()
|
||||
user, accessible = await asyncio.gather(
|
||||
gitea_proxy.current_user(), gitea_proxy.repository_access(repository)
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if not login or repository not in accessible:
|
||||
if not login or accessible is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
if creation.label_ids:
|
||||
available_labels = await gitea_proxy.repo_labels(repository)
|
||||
@@ -1945,8 +2404,22 @@ async def comment_on_assigned_issue(
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
|
||||
async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
async def close_assigned_issue(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="close_issue",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
|
||||
async def close_issue():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
@@ -2179,11 +2652,21 @@ async def comment_on_assigned_pull(
|
||||
@app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge")
|
||||
async def merge_assigned_pull(
|
||||
submission: PullMergeSubmission,
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
step_up_grant: str | None = Header(
|
||||
default=None, alias="X-Step-Up-Grant", max_length=128
|
||||
),
|
||||
):
|
||||
repository = f"{owner}/{repo}"
|
||||
await _require_step_up(
|
||||
request,
|
||||
step_up_grant,
|
||||
action="merge_pull",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
|
||||
async def merge_pull():
|
||||
if not await gitea_proxy.is_assigned_pull(repository, number):
|
||||
|
||||
+306
-52
@@ -2,11 +2,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Protocol
|
||||
@@ -14,6 +19,10 @@ from urllib import error, request
|
||||
|
||||
|
||||
PRIORITY = {"P0": 0, "P1": 1, "P2": 2}
|
||||
RUNTIME_ENV_NAMES = (
|
||||
"HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "SHELL",
|
||||
"SSH_AUTH_SOCK", "TMPDIR", "USER",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -31,6 +40,7 @@ class CommandResult:
|
||||
returncode: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -42,6 +52,10 @@ class EngineConfig:
|
||||
agent_command: str
|
||||
test_command: str
|
||||
base_branch: str = "main"
|
||||
agent_timeout_seconds: float = 1800.0
|
||||
test_timeout_seconds: float = 900.0
|
||||
termination_grace_seconds: float = 5.0
|
||||
agent_env_vars: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,6 +66,9 @@ class RunState:
|
||||
evidence: str = ""
|
||||
pr_number: int | None = None
|
||||
pr_url: str = ""
|
||||
schema_version: int = 1
|
||||
repo: str = ""
|
||||
head_sha: str = ""
|
||||
|
||||
|
||||
class Client(Protocol):
|
||||
@@ -63,7 +80,14 @@ class Client(Protocol):
|
||||
|
||||
|
||||
class Runner(Protocol):
|
||||
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult: ...
|
||||
|
||||
|
||||
class StateStore:
|
||||
@@ -77,9 +101,71 @@ class StateStore:
|
||||
|
||||
def save(self, state: RunState) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(asdict(state), indent=2) + "\n")
|
||||
tmp.replace(self.path)
|
||||
temporary_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=self.path.parent,
|
||||
prefix=self.path.name + ".",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temporary:
|
||||
temporary_path = Path(temporary.name)
|
||||
temporary.write(json.dumps(asdict(state), indent=2) + "\n")
|
||||
temporary.flush()
|
||||
os.fsync(temporary.fileno())
|
||||
os.replace(temporary_path, self.path)
|
||||
directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if temporary_path is not None:
|
||||
temporary_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class RepositoryLease:
|
||||
"""Non-blocking, process-backed single-writer lease for one worktree."""
|
||||
|
||||
def __init__(self, repo_path: Path):
|
||||
self.path = Path(repo_path).resolve() / ".release-engine" / "run.lock"
|
||||
self._file = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_file = self.path.open("a+", encoding="utf-8")
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.EACCES, errno.EAGAIN):
|
||||
detail = ""
|
||||
try:
|
||||
lock_file.seek(0)
|
||||
metadata = json.loads(lock_file.read(512))
|
||||
owner_pid = int(metadata["pid"])
|
||||
acquired_at = float(metadata["acquired_at"])
|
||||
detail = f" (owner pid={owner_pid}, acquired_at={acquired_at:.3f})"
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
lock_file.close()
|
||||
raise RuntimeError(f"release run already active{detail}") from exc
|
||||
lock_file.close()
|
||||
raise
|
||||
self._file = lock_file
|
||||
lock_file.seek(0)
|
||||
lock_file.truncate()
|
||||
lock_file.write(json.dumps({"pid": os.getpid(), "acquired_at": time.time()}) + "\n")
|
||||
lock_file.flush()
|
||||
os.fsync(lock_file.fileno())
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
if self._file is not None:
|
||||
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
|
||||
self._file.close()
|
||||
self._file = None
|
||||
|
||||
|
||||
def _label_names(labels: Iterable[Any]) -> list[str]:
|
||||
@@ -113,20 +199,70 @@ def branch_name(agent: str, issue: Issue) -> str:
|
||||
return f"{actor}/{issue.number}-{slug or 'issue'}"
|
||||
|
||||
|
||||
def parse_command(command: str, name: str) -> list[str]:
|
||||
try:
|
||||
arguments = shlex.split(command)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid {name}: {exc}") from exc
|
||||
if not arguments:
|
||||
raise ValueError(f"invalid {name}: command is empty")
|
||||
return arguments
|
||||
|
||||
|
||||
def subprocess_environment(
|
||||
allowed_names: Iterable[str] = (),
|
||||
overrides: dict[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build an explicit child environment without inheriting parent secrets."""
|
||||
environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
||||
for name in (*RUNTIME_ENV_NAMES, *allowed_names):
|
||||
if name in os.environ:
|
||||
environment[name] = os.environ[name]
|
||||
if overrides:
|
||||
environment.update(overrides)
|
||||
return environment
|
||||
|
||||
|
||||
def validate_agent_env_vars(names: Iterable[str]) -> None:
|
||||
for name in names:
|
||||
if (
|
||||
re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None
|
||||
or name == "GITEA_TOKEN"
|
||||
or name.startswith("RELEASE_")
|
||||
):
|
||||
raise ValueError(f"invalid agent environment variable: {name}")
|
||||
|
||||
|
||||
class ShellRunner:
|
||||
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
|
||||
merged_env = os.environ.copy()
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
completed = subprocess.run(
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
termination_grace_seconds: float = 5.0,
|
||||
) -> CommandResult:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
env=merged_env,
|
||||
shell=True,
|
||||
env=dict(env or {}),
|
||||
shell=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
return CommandResult(completed.returncode, completed.stdout, completed.stderr)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
||||
return CommandResult(process.returncode, stdout, stderr)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=termination_grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
stdout, stderr = process.communicate()
|
||||
return CommandResult(process.returncode, stdout, stderr, timed_out=True)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
@@ -197,8 +333,23 @@ class ReleaseEngine:
|
||||
self.store = StateStore(config.state_path)
|
||||
|
||||
def execute(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
|
||||
with RepositoryLease(self.config.repo_path):
|
||||
return self._execute_locked(issue_number=issue_number, dry_run=dry_run)
|
||||
|
||||
def _execute_locked(self, issue_number: int | None = None, dry_run: bool = False) -> dict[str, Any]:
|
||||
deadlines = {
|
||||
"agent timeout seconds": self.config.agent_timeout_seconds,
|
||||
"test timeout seconds": self.config.test_timeout_seconds,
|
||||
"termination grace seconds": self.config.termination_grace_seconds,
|
||||
}
|
||||
for name, value in deadlines.items():
|
||||
if value <= 0:
|
||||
raise ValueError(f"{name} must be positive")
|
||||
validate_agent_env_vars(self.config.agent_env_vars)
|
||||
|
||||
issue = select_issue(self.client.list_open_issues(self.config.repo), self.config.agent, issue_number)
|
||||
branch = branch_name(self.config.agent, issue)
|
||||
head_sha = ""
|
||||
|
||||
existing_pr = self.client.find_open_pr(self.config.repo, branch)
|
||||
if existing_pr:
|
||||
@@ -220,50 +371,138 @@ class ReleaseEngine:
|
||||
"agent": self.config.agent,
|
||||
}
|
||||
|
||||
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
|
||||
verified = self.client.get_issue(self.config.repo, issue.number)
|
||||
if verified.assignee != self.config.agent:
|
||||
raise RuntimeError(
|
||||
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
|
||||
agent_command = parse_command(self.config.agent_command, "agent command")
|
||||
test_command = parse_command(self.config.test_command, "test command")
|
||||
runtime_env = subprocess_environment()
|
||||
agent_env = subprocess_environment(
|
||||
self.config.agent_env_vars,
|
||||
{
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
},
|
||||
)
|
||||
checkpoint = self.store.load()
|
||||
resumable_statuses = {"agent_complete", "tests_passed", "pushed"}
|
||||
if checkpoint is not None and checkpoint.status in resumable_statuses:
|
||||
if (
|
||||
checkpoint.schema_version != 1
|
||||
or checkpoint.repo != self.config.repo
|
||||
or checkpoint.issue != issue.number
|
||||
or checkpoint.branch != branch
|
||||
or not checkpoint.head_sha
|
||||
):
|
||||
raise RuntimeError("release checkpoint does not match this run")
|
||||
branch_result = self.runner.run(
|
||||
["git", "checkout", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
self.store.save(RunState(issue.number, branch, "claimed"))
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"checkpoint branch checkout failed: {branch_result.stderr.strip()}")
|
||||
head_result = self.runner.run(
|
||||
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
|
||||
)
|
||||
if head_result.returncode != 0 or head_result.stdout.strip() != checkpoint.head_sha:
|
||||
raise RuntimeError("release checkpoint commit does not match the local branch")
|
||||
status_result = self.runner.run(
|
||||
["git", "status", "--porcelain"], self.config.repo_path, runtime_env
|
||||
)
|
||||
if status_result.returncode != 0 or status_result.stdout.strip():
|
||||
raise RuntimeError("release checkpoint worktree is not clean")
|
||||
head_sha = checkpoint.head_sha
|
||||
evidence = checkpoint.evidence
|
||||
else:
|
||||
checkpoint = None
|
||||
evidence = ""
|
||||
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
|
||||
verified = self.client.get_issue(self.config.repo, issue.number)
|
||||
if verified.assignee != self.config.agent:
|
||||
raise RuntimeError(
|
||||
f"claim verification failed: expected {self.config.agent}, got {verified.assignee}"
|
||||
)
|
||||
self.store.save(RunState(issue.number, branch, "claimed"))
|
||||
|
||||
branch_result = self.runner.run(f"git checkout -B {shlex.quote(branch)}", self.config.repo_path)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
branch_result = self.runner.run(
|
||||
["git", "checkout", "-B", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if branch_result.returncode != 0:
|
||||
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
|
||||
|
||||
context = {
|
||||
"issue_number": issue.number,
|
||||
"issue_title": issue.title,
|
||||
"issue_body": issue.body,
|
||||
"repo": self.config.repo,
|
||||
"branch": branch,
|
||||
}
|
||||
command = self.config.agent_command.format(**context)
|
||||
agent_result = self.runner.run(command, self.config.repo_path, {
|
||||
"RELEASE_ISSUE_NUMBER": str(issue.number),
|
||||
"RELEASE_ISSUE_TITLE": issue.title,
|
||||
"RELEASE_ISSUE_BODY": issue.body,
|
||||
"RELEASE_REPO": self.config.repo,
|
||||
"RELEASE_BRANCH": branch,
|
||||
})
|
||||
if agent_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
self.store.save(RunState(issue.number, branch, "agent_complete"))
|
||||
agent_result = self.runner.run(
|
||||
agent_command,
|
||||
self.config.repo_path,
|
||||
agent_env,
|
||||
timeout_seconds=self.config.agent_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
if agent_result.timed_out:
|
||||
evidence = (agent_result.stdout + "\n" + agent_result.stderr).strip()[-2000:]
|
||||
self.store.save(RunState(issue.number, branch, "agent_timed_out", evidence))
|
||||
raise RuntimeError(f"coding agent timed out: {evidence}")
|
||||
if agent_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "agent_failed", agent_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
|
||||
head_result = self.runner.run(
|
||||
["git", "rev-parse", "HEAD"], self.config.repo_path, runtime_env
|
||||
)
|
||||
head_sha = head_result.stdout.strip()
|
||||
if head_result.returncode != 0 or not head_sha:
|
||||
raise RuntimeError("could not identify the release checkpoint commit")
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "agent_complete",
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
|
||||
test_result = self.runner.run(self.config.test_command, self.config.repo_path)
|
||||
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
|
||||
if test_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
|
||||
raise RuntimeError(f"tests failed: {evidence}")
|
||||
self.store.save(RunState(issue.number, branch, "tests_passed", evidence))
|
||||
if checkpoint is None or checkpoint.status == "agent_complete":
|
||||
test_result = self.runner.run(
|
||||
test_command,
|
||||
self.config.repo_path,
|
||||
runtime_env,
|
||||
timeout_seconds=self.config.test_timeout_seconds,
|
||||
termination_grace_seconds=self.config.termination_grace_seconds,
|
||||
)
|
||||
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
|
||||
if test_result.timed_out:
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "tests_timed_out", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
raise RuntimeError(f"tests timed out: {evidence}")
|
||||
if test_result.returncode != 0:
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "tests_failed", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
raise RuntimeError(f"tests failed: {evidence}")
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "tests_passed", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
|
||||
push_result = self.runner.run(f"git push -u origin {shlex.quote(branch)}", self.config.repo_path)
|
||||
if push_result.returncode != 0:
|
||||
self.store.save(RunState(issue.number, branch, "push_failed", push_result.stderr[-2000:]))
|
||||
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
|
||||
self.store.save(RunState(issue.number, branch, "pushed", evidence))
|
||||
if checkpoint is not None and checkpoint.status == "pushed":
|
||||
remote_result = self.runner.run(
|
||||
["git", "ls-remote", "--heads", "origin", branch],
|
||||
self.config.repo_path,
|
||||
runtime_env,
|
||||
)
|
||||
expected_remote = f"{checkpoint.head_sha}\trefs/heads/{branch}"
|
||||
if remote_result.returncode != 0 or remote_result.stdout.strip() != expected_remote:
|
||||
raise RuntimeError("release checkpoint commit does not match the remote branch")
|
||||
else:
|
||||
push_result = self.runner.run(
|
||||
["git", "push", "-u", "origin", branch], self.config.repo_path, runtime_env
|
||||
)
|
||||
if push_result.returncode != 0:
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "push_failed", push_result.stderr[-2000:],
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
|
||||
self.store.save(RunState(
|
||||
issue.number, branch, "pushed", evidence,
|
||||
repo=self.config.repo, head_sha=head_sha,
|
||||
))
|
||||
|
||||
pr_body = (
|
||||
f"Closes #{issue.number}\n\n"
|
||||
@@ -288,6 +527,8 @@ class ReleaseEngine:
|
||||
evidence,
|
||||
pr_number=pr["number"],
|
||||
pr_url=pr.get("html_url", ""),
|
||||
repo=self.config.repo,
|
||||
head_sha=head_sha,
|
||||
)
|
||||
self.store.save(final)
|
||||
return {
|
||||
@@ -310,6 +551,15 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--api", default=os.getenv("GITEA_API", "http://127.0.0.1:3000/api/v1"))
|
||||
parser.add_argument("--agent-command", default=os.getenv("RELEASE_AGENT_COMMAND", ""))
|
||||
parser.add_argument("--test-command", default=os.getenv("RELEASE_TEST_COMMAND", "python3 -m pytest tests/ -q"))
|
||||
parser.add_argument("--agent-timeout", type=float, default=float(os.getenv("RELEASE_AGENT_TIMEOUT", "1800")))
|
||||
parser.add_argument("--test-timeout", type=float, default=float(os.getenv("RELEASE_TEST_TIMEOUT", "900")))
|
||||
parser.add_argument("--termination-grace", type=float, default=float(os.getenv("RELEASE_TERMINATION_GRACE", "5")))
|
||||
parser.add_argument(
|
||||
"--agent-env",
|
||||
action="append",
|
||||
default=[name for name in os.getenv("RELEASE_AGENT_ENV", "").split(",") if name],
|
||||
help="parent environment variable to pass to the coding agent; repeat as needed",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -327,6 +577,10 @@ def main() -> int:
|
||||
state_path=args.state,
|
||||
agent_command=args.agent_command,
|
||||
test_command=args.test_command,
|
||||
agent_timeout_seconds=args.agent_timeout,
|
||||
test_timeout_seconds=args.test_timeout,
|
||||
termination_grace_seconds=args.termination_grace,
|
||||
agent_env_vars=tuple(args.agent_env),
|
||||
)
|
||||
result = ReleaseEngine(config, GiteaClient(args.api, token), ShellRunner()).execute(
|
||||
issue_number=args.issue,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
def request_body_limit(method: str, path: str) -> int | None:
|
||||
"""Return the admission limit for request bodies that FastAPI will parse."""
|
||||
normalized_method = method.upper()
|
||||
if normalized_method == "POST" and path == "/api/v1/session":
|
||||
return SESSION_BODY_LIMIT
|
||||
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
|
||||
return API_MUTATION_BODY_LIMIT
|
||||
return None
|
||||
|
||||
|
||||
class RequestBodyLimitMiddleware:
|
||||
"""Reject oversized API bodies before framework parsing or endpoint work."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: ASGIApp,
|
||||
limit_for: Callable[[str, str], int | None] = request_body_limit,
|
||||
) -> None:
|
||||
self.app = app
|
||||
self.limit_for = limit_for
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
root_path = scope.get("root_path", "").rstrip("/")
|
||||
if root_path and (path == root_path or path.startswith(root_path + "/")):
|
||||
path = path[len(root_path) :] or "/"
|
||||
limit = self.limit_for(scope.get("method", "GET"), path)
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
declared_length = self._content_length(scope)
|
||||
if declared_length is not None and declared_length > limit:
|
||||
await self._reject(send)
|
||||
return
|
||||
|
||||
messages: list[Message] = []
|
||||
received = 0
|
||||
while True:
|
||||
message = await receive()
|
||||
messages.append(message)
|
||||
if message["type"] != "http.request":
|
||||
break
|
||||
received += len(message.get("body", b""))
|
||||
if received > limit:
|
||||
await self._reject(send)
|
||||
return
|
||||
if not message.get("more_body", False):
|
||||
break
|
||||
|
||||
async def replay() -> Message:
|
||||
if messages:
|
||||
return messages.pop(0)
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
||||
@staticmethod
|
||||
def _content_length(scope: Scope) -> int | None:
|
||||
for name, value in scope.get("headers", []):
|
||||
if name.lower() != b"content-length":
|
||||
continue
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return max(0, parsed)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _reject(send: Send) -> None:
|
||||
body = b'{"detail":"Request body too large"}'
|
||||
headers = [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"content-length", str(len(body)).encode("ascii")),
|
||||
(b"cache-control", b"no-store"),
|
||||
]
|
||||
await send({"type": "http.response.start", "status": 413, "headers": headers})
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
+199
-6
@@ -1,7 +1,9 @@
|
||||
"""Durable active-session registry used to revoke signed operator sessions."""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
@@ -10,6 +12,15 @@ class SessionStoreError(RuntimeError):
|
||||
"""Raised when session state cannot be read or changed safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveDevice:
|
||||
management_id: str
|
||||
device_label: str
|
||||
created_at: int
|
||||
expires_at: int
|
||||
current: bool
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -44,23 +55,82 @@ class SessionStore:
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
session_hash TEXT PRIMARY KEY,
|
||||
expires_at INTEGER NOT NULL,
|
||||
management_id TEXT,
|
||||
device_label TEXT,
|
||||
created_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS step_up_grants (
|
||||
grant_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS step_up_grants_session_hash "
|
||||
"ON step_up_grants(session_hash)"
|
||||
)
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(active_sessions)")
|
||||
}
|
||||
additions = {
|
||||
"management_id": "TEXT",
|
||||
"device_label": "TEXT",
|
||||
"created_at": "INTEGER",
|
||||
}
|
||||
for name, column_type in additions.items():
|
||||
if name not in columns:
|
||||
connection.execute(
|
||||
f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) "
|
||||
"WHERE management_id IS NULL"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET device_label = 'Existing device' "
|
||||
"WHERE device_label IS NULL"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
||||
(int(self.clock()),),
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
||||
"ON active_sessions(management_id)"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def activate(self, session_id: str, expires_at: int) -> None:
|
||||
def activate(
|
||||
self, session_id: str, expires_at: int, *, device_label: str = "This device"
|
||||
) -> None:
|
||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE expires_at <= ?", (int(self.clock()),)
|
||||
"DELETE FROM active_sessions WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO active_sessions(session_hash, expires_at) VALUES (?, ?)",
|
||||
(self._digest(session_id), expires_at),
|
||||
"INSERT INTO active_sessions("
|
||||
"session_hash, expires_at, management_id, device_label, created_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(session_id),
|
||||
expires_at,
|
||||
secrets.token_urlsafe(18),
|
||||
label,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -79,7 +149,11 @@ class SessionStore:
|
||||
|
||||
def revoke(self, session_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
@@ -87,9 +161,128 @@ class SessionStore:
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
|
||||
now = int(self.clock())
|
||||
current_hash = self._digest(current_session_id)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, device_label, created_at, expires_at, session_hash "
|
||||
"FROM active_sessions WHERE expires_at > ? "
|
||||
"ORDER BY expires_at DESC, created_at DESC",
|
||||
(now,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return [
|
||||
ActiveDevice(
|
||||
management_id=row[0],
|
||||
device_label=row[1],
|
||||
created_at=row[2],
|
||||
expires_at=row[3],
|
||||
current=secrets.compare_digest(row[4], current_hash),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def mint_step_up(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
ttl_seconds: int,
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
grant = secrets.token_urlsafe(32)
|
||||
session_hash = self._digest(session_id)
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
active = connection.execute(
|
||||
"SELECT 1 FROM active_sessions "
|
||||
"WHERE session_hash = ? AND expires_at > ?",
|
||||
(session_hash, now),
|
||||
).fetchone()
|
||||
if active is None:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO step_up_grants("
|
||||
"grant_hash, session_hash, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(grant),
|
||||
session_hash,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
except SessionStoreError:
|
||||
raise
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return grant
|
||||
|
||||
def consume_step_up(
|
||||
self,
|
||||
grant: str,
|
||||
session_id: str,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE grant_hash = ? "
|
||||
"AND session_hash = ? AND action = ? AND target = ? "
|
||||
"AND expires_at > ? AND EXISTS ("
|
||||
"SELECT 1 FROM active_sessions "
|
||||
"WHERE active_sessions.session_hash = step_up_grants.session_hash "
|
||||
"AND active_sessions.expires_at > ?)",
|
||||
(
|
||||
self._digest(grant),
|
||||
self._digest(session_id),
|
||||
action,
|
||||
target,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Durable, account-scoped ordered Today plans."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
"""Raised when an add would exceed the bounded Today plan."""
|
||||
|
||||
|
||||
class TodayStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 5,
|
||||
timeout: float = 1.0,
|
||||
operation_limit: int = 4096,
|
||||
operation_retention_seconds: float = 30 * 24 * 60 * 60,
|
||||
clock=time.time,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self.operation_limit = operation_limit
|
||||
self.operation_retention_seconds = operation_retention_seconds
|
||||
self.clock = clock
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")}
|
||||
if "created_at" not in columns:
|
||||
connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL")
|
||||
connection.execute(
|
||||
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
|
||||
(self.clock(),),
|
||||
)
|
||||
connection.commit()
|
||||
return connection
|
||||
|
||||
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
|
||||
now = self.clock()
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)",
|
||||
(login, operation_id, now),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_operations WHERE login = ? AND created_at < ?",
|
||||
(login, now - self.operation_retention_seconds),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM today_operations WHERE login = ? AND rowid NOT IN "
|
||||
"(SELECT rowid FROM today_operations WHERE login = ? "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(login, login, self.operation_limit),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": []}
|
||||
return {"revision": int(row[0]), "ids": json.loads(row[1])}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
operation_id: str,
|
||||
action: str,
|
||||
item_id: str,
|
||||
*,
|
||||
direction: str | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
return snapshot
|
||||
|
||||
ids = list(snapshot["ids"])
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
if len(ids) >= self.limit:
|
||||
raise TodayPlanFull("Today is limited to five items")
|
||||
ids.append(item_id)
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, json.dumps(ids, separators=(",", ":"))),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {"revision": revision, "ids": ids}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
action = operation.get("action", "")
|
||||
item_id = operation.get("item_id", "")
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
base_revision = operation.get("base_revision")
|
||||
if base_revision is None:
|
||||
base_revision = snapshot["revision"]
|
||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||
raise ValueError("base_revision must be a non-negative integer")
|
||||
if base_revision < snapshot["revision"]:
|
||||
self._record_operation(connection, login, operation_id)
|
||||
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
if len(ids) >= self.limit:
|
||||
rejected.append({"operation_id": operation_id, "reason": "today_full"})
|
||||
continue
|
||||
ids.append(item_id)
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
revision += 1 if changed else 0
|
||||
self._record_operation(connection, login, operation_id)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
+23
-10
@@ -1,20 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
router = APIRouter()
|
||||
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
|
||||
MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest"
|
||||
SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js"
|
||||
FRONTEND_BUILD = build_frontend(DASHBOARD_FILE.parent)
|
||||
|
||||
LOGIN_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
|
||||
<script src="static/login.js"></script></body></html>"""
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
class RevalidatingHTMLResponse(HTMLResponse):
|
||||
@@ -26,7 +28,7 @@ class RevalidatingHTMLResponse(HTMLResponse):
|
||||
|
||||
@router.get("/", response_class=RevalidatingHTMLResponse)
|
||||
async def dashboard() -> str:
|
||||
return DASHBOARD_FILE.read_text()
|
||||
return FRONTEND_BUILD.dashboard_html
|
||||
|
||||
|
||||
@router.get("/login", response_class=RevalidatingHTMLResponse)
|
||||
@@ -39,10 +41,21 @@ async def web_app_manifest() -> FileResponse:
|
||||
return FileResponse(MANIFEST_FILE, media_type="application/manifest+json")
|
||||
|
||||
|
||||
@router.get("/service-worker.js", response_class=FileResponse)
|
||||
async def service_worker() -> FileResponse:
|
||||
return FileResponse(
|
||||
SERVICE_WORKER_FILE,
|
||||
@router.get("/service-worker.js")
|
||||
async def service_worker() -> Response:
|
||||
return Response(
|
||||
FRONTEND_BUILD.service_worker_source,
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runtime-{digest}.js")
|
||||
async def runtime_bundle(digest: str) -> Response:
|
||||
if digest != FRONTEND_BUILD.runtime_digest:
|
||||
raise HTTPException(status_code=404, detail="Runtime revision not found")
|
||||
return Response(
|
||||
FRONTEND_BUILD.runtime_bytes,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from src.views import dashboard as dashboard_html
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT_MANIFEST = "\n".join(
|
||||
re.findall(
|
||||
r'^<script src="static/[^\"]+\.js"></script>$',
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
re.MULTILINE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def dashboard_bundle_text() -> str:
|
||||
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
|
||||
return "\n".join(
|
||||
(
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# Exercise the real view lookup before adding its separately served bootstrap assets.
|
||||
html = await dashboard_html()
|
||||
return "\n".join(
|
||||
(
|
||||
html,
|
||||
PAGE_SCRIPT_MANIFEST,
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
@@ -2,12 +2,11 @@ import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
|
||||
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
def test_api_requests_resolve_inside_dashboard_subpath():
|
||||
html = DASHBOARD_HTML.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
|
||||
|
||||
assert api_paths
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
||||
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const issue={{repository:'stackchain/dashboard',number:411}};
|
||||
const first=flow.run(issue);
|
||||
const second=flow.run(issue);
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"calls": [
|
||||
"claim:411",
|
||||
"start:411",
|
||||
"announce:Assigned, added to Today, and ready to work.",
|
||||
],
|
||||
"results": ["started", "started"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_full_today_blocks_before_assignment_and_explains_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>false,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
|
||||
start:()=>calls.push('start'),
|
||||
recover:()=>calls.push('recover'),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "full",
|
||||
"calls": ["announce:Today is full—remove an item before assigning this issue."],
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
|
||||
"recover:411",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_failed_assignment_does_not_start_today_and_can_be_retried():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];let attempt=0;
|
||||
const issue={{repository:'stackchain/dashboard',number:411}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
|
||||
start:()=>{{calls.push('start');return 'started';}},
|
||||
recover:()=>calls.push('recover'),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue).catch(error=>{{
|
||||
calls.push('error:'+error.message);
|
||||
return flow.run(issue);
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"claim:1",
|
||||
"error:conflict",
|
||||
"claim:2",
|
||||
"start",
|
||||
"announce:Assigned, added to Today, and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_already_owned_search_result_starts_without_claiming():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{alreadyOwned:true}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"start:413",
|
||||
"announce:Added to Today and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_claims_once_without_starting_the_active_session():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const issue={{repository:'stackchain/dashboard',number:415}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const first=flow.run(issue, {{destination:'queue'}});
|
||||
const second=flow.run(issue, {{destination:'queue'}});
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
|
||||
calls,results,same:first===second
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"claim:415",
|
||||
"queue:415",
|
||||
"announce:Assigned and queued in Today. Keep finding work when ready.",
|
||||
],
|
||||
"results": ["queued", "queued"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
|
||||
const calls=[];
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
|
||||
const queue=createQueueToday({{
|
||||
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),
|
||||
warm:()=>calls.push('warm'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "queued",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:415:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'started',
|
||||
queue:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{destination:'queue'}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
|
||||
"recover:415",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "data-claim-start-index" in dashboard
|
||||
assert "data-claim-queue-index" in dashboard
|
||||
assert ">Start now</button>" in dashboard
|
||||
assert ">Queue Today</button>" in dashboard
|
||||
assert "const assignAndStart = createAssignAndStart({" in dashboard
|
||||
assert "assignAndStart.run(item)" in dashboard
|
||||
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
|
||||
assert "queue: confirmed =>" in dashboard
|
||||
assert "const queueToday = createQueueToday({" in dashboard
|
||||
assert "return queueToday(claimed)" in dashboard
|
||||
assert "createAndStart.available" in dashboard
|
||||
assert "createAndStart.complete" in dashboard
|
||||
assert ".find-work-claim-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
assert ".find-work-claim-actions [data-claim-start-index]" in css
|
||||
assert "grid-column:1 / -1" in css
|
||||
assert "BASE + 'static/assign-and-start.js'" in worker
|
||||
assert "BASE + 'static/queue-today.js'" in worker
|
||||
assert '<script src="static/queue-today.js"></script>' in html
|
||||
@@ -116,6 +116,54 @@ async def test_notification_reply_retry_recovers_after_caller_timeout(monkeypatc
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_authored_task_remains_joinable_after_reservation_age_threshold(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
now = [1_000.0]
|
||||
release = asyncio.Event()
|
||||
|
||||
async def reply(_thread_id, body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await release.wait()
|
||||
return {"id": 319, "body": body}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"_idempotency_ledger",
|
||||
IdempotencyLedger(
|
||||
tmp_path / "live.sqlite3",
|
||||
ttl_seconds=1,
|
||||
max_entries=256,
|
||||
clock=lambda: now[0],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "reply_to_notification", reply)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "live-after-threshold-319"}
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
timed_out = await client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers,
|
||||
)
|
||||
now[0] += 2
|
||||
monkeypatch.setattr(main, "NOTIFICATION_MUTATION_TIMEOUT_SECONDS", 1.0)
|
||||
joined = asyncio.create_task(client.post(
|
||||
"/api/v1/notifications/42/reply", json={"body": "Join me"}, headers=headers,
|
||||
))
|
||||
await asyncio.sleep(0.02)
|
||||
assert not joined.done(), "a live local operation must be joined instead of rejected as stale"
|
||||
release.set()
|
||||
recovered = await joined
|
||||
|
||||
assert timed_out.status_code == 503
|
||||
assert recovered.status_code == 201
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_review_replays_same_key_and_rejects_changed_head(monkeypatch):
|
||||
calls = []
|
||||
@@ -233,6 +281,51 @@ async def test_orphaned_pending_comment_fails_closed_without_upstream_retry(
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_orphaned_comment_returns_non_retryable_uncertain_delivery(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
calls = 0
|
||||
now = [1_000.0]
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return True
|
||||
|
||||
async def comment(_repository, _number, _body):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 319}
|
||||
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "stale-orphan.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=256,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
assert ledger.reserve("stale-orphan-319", fingerprint).state == "reserved"
|
||||
now[0] += 11
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/7/comments",
|
||||
json={"body": "Verify first"},
|
||||
headers={"Idempotency-Key": "stale-orphan-319"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"] == {
|
||||
"code": "delivery_uncertain",
|
||||
"message": "Delivery could not be confirmed. Verify it was not posted before retrying.",
|
||||
}
|
||||
assert "Retry-After" not in response.headers
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_slow_ledger_reservation_does_not_block_health_requests(monkeypatch):
|
||||
async def assigned(_repository, _number):
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
|
||||
@@ -113,6 +113,37 @@ outbox.flush('timmy').then(async first => {{
|
||||
assert output["remaining"][1]["status"] == "queued"
|
||||
|
||||
|
||||
def test_authored_outbox_requires_deliberate_rekey_after_uncertain_delivery():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let sequence=0; const calls=[];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'verified-' + (++sequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
const key=options.headers['Idempotency-Key']; calls.push(key);
|
||||
if (key === 'uncertain-key') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
|
||||
return {{id:calls.length}};
|
||||
}},
|
||||
}});
|
||||
const uncertain=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Check',operationId:'uncertain-key'}});
|
||||
outbox.enqueue({{kind:'pull-comment',repository:'o/r',number:2,body:'Continue',operationId:'other-key'}});
|
||||
(async()=>{{
|
||||
const first=await outbox.flush('timmy');
|
||||
const attention=outbox.list()[0];
|
||||
const retried=await outbox.retry(uncertain.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{first,attention,retried,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == ["uncertain-key", "other-key", "verified-1"]
|
||||
assert len(output["first"]["confirmed"]) == 1
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["deliveryState"] == "uncertain"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_retry_is_single_flight_and_edit_rotates_identity():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -182,6 +213,21 @@ setTimeout(() => {{
|
||||
]
|
||||
|
||||
|
||||
def test_authored_outbox_reconciles_uncertain_worker_delivery_state():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map(); const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy'}});
|
||||
const item=outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:7,body:'Maybe',operationId:'maybe'}});
|
||||
const reconciled=outbox.reconcileBackground([{{...item,status:'attention',deliveryState:'uncertain',error:'Verify first'}}]);
|
||||
process.stdout.write(JSON.stringify(reconciled[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["status"] == "attention"
|
||||
assert output["deliveryState"] == "uncertain"
|
||||
|
||||
|
||||
def test_authored_outbox_waits_for_durable_background_admission():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -6,7 +6,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
@@ -64,6 +64,38 @@ const fetchJson = async (url, options = {{}}) => {{
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{
|
||||
id:'capture-start',operationId:'capture-start',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Resume',body:'',labelIds:[],
|
||||
completionIntent:'create-and-start',
|
||||
}};
|
||||
let queued = item;
|
||||
const state = {{completed:[]}};
|
||||
const store = {{
|
||||
claimNext:async () => queued ? (queued=null,{{...item}}) : null,
|
||||
complete:async (id, deliveredIssue) => state.completed.push({{id,deliveredIssue}}),
|
||||
release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async url => url === 'api/v1/background-identity' ? {{login:'timmy'}} :
|
||||
{{repository:'stackchain/dashboard',number:389,title:'Resume'}};
|
||||
(async()=>{{
|
||||
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["completed"] == [{
|
||||
"id": "capture-start",
|
||||
"deliveredIssue": {
|
||||
"repository": "stackchain/dashboard", "number": 389, "title": "Resume"
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_closed_app_sync_returns_privacy_safe_actionable_delivery_receipts():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -194,6 +226,39 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state={{opens:0,closes:0}};
|
||||
const indexedDB={{lastDb:null,open:()=>{{
|
||||
state.opens += 1;
|
||||
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
|
||||
const db={{
|
||||
objectStoreNames:{{contains:()=>true}},
|
||||
close:()=>{{state.closes += 1;}},
|
||||
transaction:()=>{{
|
||||
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
|
||||
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
|
||||
}})}};
|
||||
setTimeout(()=>tx.oncomplete?.(),0);
|
||||
return tx;
|
||||
}},
|
||||
}};
|
||||
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
|
||||
}}}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
|
||||
await store.snapshot();
|
||||
indexedDB.lastDb.onversionchange?.();
|
||||
await store.snapshot();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"opens": 2, "closes": 1}
|
||||
|
||||
|
||||
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -326,6 +391,203 @@ const fetchJson = async url => {{
|
||||
assert output["error"] == "Gitea unavailable"
|
||||
|
||||
|
||||
def test_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
||||
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
|
||||
const store = {{
|
||||
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => {{state.released.push(id); state.queued=true;}},
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.keys.push(options.headers['Idempotency-Key']);
|
||||
state.attempts += 1;
|
||||
if (state.attempts === 1) return new Promise(() => {{}});
|
||||
return {{repository:'o/r',number:9}};
|
||||
}};
|
||||
(async () => {{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
|
||||
let firstError='';
|
||||
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Background request timed out."
|
||||
assert output["state"]["released"] == ["capture-timeout"]
|
||||
assert output["state"]["completed"] == ["capture-timeout"]
|
||||
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
|
||||
assert output["second"]["confirmed"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items=[
|
||||
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
|
||||
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
|
||||
];
|
||||
const state={{released:[],closed:0,mutations:0}};
|
||||
let mutationStarted;
|
||||
const started=new Promise(resolve => mutationStarted=resolve);
|
||||
const store={{
|
||||
claimBatch:async()=>items.map(item=>({{...item}})),
|
||||
complete:async()=>{{}},
|
||||
release:async id=>state.released.push(id),
|
||||
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
|
||||
sync.flush().catch(()=>{{}});
|
||||
await started;
|
||||
const outcome=await Promise.race([
|
||||
sync.purge().then(()=> 'purged'),
|
||||
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{state,outcome}}));
|
||||
process.exit(0);
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outcome"] == "purged"
|
||||
assert output["state"] == {
|
||||
"released": ["capture-purge-1", "capture-purge-2"],
|
||||
"closed": 1,
|
||||
"mutations": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = [
|
||||
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
|
||||
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
|
||||
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
|
||||
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
|
||||
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
|
||||
const store = {{
|
||||
claimBatch: async (owner, limit) => items.slice(0, limit),
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id),
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key = options.headers['Idempotency-Key'];
|
||||
state.attempted.push(key); state.active += 1;
|
||||
state.maxActive = Math.max(state.maxActive, state.active);
|
||||
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
|
||||
state.active -= 1;
|
||||
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
|
||||
return {{id:key,number:7}};
|
||||
}};
|
||||
(async () => {{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{
|
||||
store,fetchJson,maxConcurrency:3,batchSize:10,
|
||||
batch: async work => {{ state.batches += 1; return work(); }},
|
||||
}}).flush(); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["maxActive"] == 3
|
||||
assert output["state"]["batches"] == 1
|
||||
assert sorted(output["state"]["attempted"]) == [
|
||||
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
|
||||
]
|
||||
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
|
||||
assert sorted(output["state"]["completed"]) == [
|
||||
"issue-1", "issue-2", "message-1", "message-2"
|
||||
]
|
||||
assert output["state"]["released"] == ["issue-bad"]
|
||||
assert output["error"] == "Temporary outage"
|
||||
|
||||
|
||||
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = Array.from({{length:5}}, (_, index) => ({{
|
||||
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
|
||||
repository:'o/r',title:'Item',body:'',labelIds:[],
|
||||
}}));
|
||||
const state = {{attempted:[],released:[],completed:[]}};
|
||||
const store = {{
|
||||
claimBatch: async () => items,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
|
||||
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
return {{number:1}};
|
||||
}};
|
||||
(async()=>{{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
|
||||
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["attempted"] == ["item-0", "item-1"]
|
||||
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
|
||||
assert output["state"]["completed"] == ["item-1"]
|
||||
assert output["error"] == {"message": "Authentication required", "status": 401}
|
||||
|
||||
|
||||
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100,claimMs:5000}});
|
||||
await store.reconcile([
|
||||
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'issue');
|
||||
await store.reconcile([
|
||||
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'authored');
|
||||
const claimed=await store.claimBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["claimed"]] == [
|
||||
"issue-1", "message-1", "issue-2", "message-2"
|
||||
]
|
||||
assert all(item["status"] == "sending" for item in output["claimed"])
|
||||
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
||||
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -355,6 +617,31 @@ const fetchJson = async url => {{
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_uncertain_background_delivery_preserves_verification_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let claimed=false; const state={{failed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,{{id:'maybe',operationId:'maybe',ownerLogin:'timmy',repository:'o/r',title:'Maybe',labelIds:[]}}),
|
||||
fail:async(id,message,deliveryState)=>state.failed.push({{id,message,deliveryState}}),
|
||||
release:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error=new Error('Verify first'); error.status=422; error.code='delivery_uncertain'; throw error;
|
||||
}};
|
||||
(async()=>{{
|
||||
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["failed"] == [
|
||||
{"id": "maybe", "message": "Verify first", "deliveryState": "uncertain"}
|
||||
]
|
||||
|
||||
|
||||
def test_issue_store_atomically_grants_one_delivery_claim():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -15,3 +15,14 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||
install = text.index("pip install -r requirements.txt")
|
||||
tests = text.index("python3 -m pytest tests/ -q")
|
||||
assert install < tests
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: [lint, build-release]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
@@ -4,10 +4,13 @@ from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).parents[1] / "frontend"
|
||||
COMMANDS = FRONTEND / "commands.js"
|
||||
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
||||
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
@@ -41,7 +44,7 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
||||
|
||||
def test_command_script_resolves_inside_dashboard_subpath():
|
||||
parser = ScriptSourceParser()
|
||||
parser.feed((FRONTEND / "index.html").read_text())
|
||||
parser.feed(dashboard_bundle_text())
|
||||
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
||||
|
||||
assert urljoin(
|
||||
@@ -152,7 +155,7 @@ if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys sho
|
||||
|
||||
|
||||
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'role="combobox"' in html
|
||||
assert 'aria-controls="cmd-results"' in html
|
||||
@@ -221,7 +224,7 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
||||
"function renderCommands", 1
|
||||
@@ -233,8 +236,73 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
const listeners = new Map();
|
||||
const viewport = {{
|
||||
height: 430, offsetTop: 17,
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: (name, fn) => {{ if (listeners.get(name) === fn) listeners.delete(name); }},
|
||||
}};
|
||||
const values = new Map();
|
||||
const palette = {{ style: {{
|
||||
setProperty: (name, value) => values.set(name, value),
|
||||
removeProperty: name => values.delete(name),
|
||||
}} }};
|
||||
const results = {{ scrollTop: 73 }};
|
||||
const controller = createMobileSearchViewport({{
|
||||
palette, results, viewport, mediaQuery: {{ matches: true }},
|
||||
schedule: fn => fn(),
|
||||
}});
|
||||
controller.open();
|
||||
if (values.get('--search-viewport-top') !== '17px') throw new Error('viewport offset was not applied');
|
||||
if (values.get('--search-viewport-height') !== '430px') throw new Error('viewport height was not applied');
|
||||
if (listeners.size !== 2) throw new Error('viewport listeners were not attached once');
|
||||
controller.rememberScroll();
|
||||
results.scrollTop = 0;
|
||||
controller.restoreScroll();
|
||||
if (results.scrollTop !== 73) throw new Error('result scroll position was not restored');
|
||||
controller.open();
|
||||
if (listeners.size !== 2) throw new Error('duplicate viewport listeners were attached');
|
||||
controller.close();
|
||||
if (listeners.size !== 0 || values.size !== 0) throw new Error('viewport state leaked after close');
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_mobile_search_is_a_keyboard_safe_workspace_wired_to_preview_history():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'class="cmd-palette-header"' in html
|
||||
assert 'id="close-command-palette"' in html
|
||||
assert 'style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"' not in html
|
||||
assert 'src="static/mobile-search-viewport.js"' in html
|
||||
assert "height:var(--search-viewport-height,100dvh)" in css
|
||||
assert "top:var(--search-viewport-top,0px)" in css
|
||||
assert "#cmd-results { flex:1; min-height:0; overflow-y:auto;" in css
|
||||
assert "mobileSearchViewport.rememberScroll()" in html
|
||||
assert "mobileSearchViewport.restoreScroll()" in html
|
||||
assert "mobileSearchViewport.open()" in html
|
||||
assert "mobileSearchViewport.close()" in html
|
||||
@@ -114,3 +114,130 @@ const poller = createContextPoller({{
|
||||
{"context": "timmy", "event": 2, "age": 2, "changed": []},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_live_revision_query_preserves_bounded_opaque_tokens():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const query = createContextPoller.buildRevisionQuery({{
|
||||
context: '0123456789abcdef.12',
|
||||
events: 'fedcba9876543210.3',
|
||||
notifications: 'not-a-token',
|
||||
extra: '0123456789abcdef.1',
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{ query }}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"query": "context_revision=0123456789abcdef.12&events_revision=fedcba9876543210.3"
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const timers = [];
|
||||
const deadlineTimers = [];
|
||||
const errors = [];
|
||||
const snapshots = [];
|
||||
const signals = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
signals.push(options.signal);
|
||||
if (calls === 1) return new Promise(() => {{}});
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
|
||||
onError: error => errors.push({{ name: error.name, message: error.message }}),
|
||||
setTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearTimer: timer => {{ timer.cancelled = true; }},
|
||||
setDeadlineTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
deadlineTimers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
|
||||
intervalMs: 8000,
|
||||
timeoutMs: 25,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
|
||||
deadline.callback();
|
||||
await first;
|
||||
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
|
||||
retry.callback();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: signals[0].aborted,
|
||||
errors,
|
||||
snapshots,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"errors": [
|
||||
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
|
||||
],
|
||||
"snapshots": ["timmy"],
|
||||
}
|
||||
|
||||
|
||||
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let resolveFirst;
|
||||
let firstSignal;
|
||||
const rendered = [];
|
||||
const errors = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) {{
|
||||
firstSignal = options.signal;
|
||||
return new Promise(resolve => {{ resolveFirst = resolve; }});
|
||||
}}
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
|
||||
onError: error => errors.push(error.name),
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
setDeadlineTimer: () => 1,
|
||||
clearDeadlineTimer: () => {{}},
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const stale = poller.start();
|
||||
const fresh = poller.refresh({{ force: true }});
|
||||
await fresh;
|
||||
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
|
||||
await stale;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: firstSignal.aborted,
|
||||
rendered,
|
||||
errors,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"rendered": ["fresh"],
|
||||
"errors": [],
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CREATE_AND_START = Path(__file__).parents[1] / "frontend" / "create-and-start.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_confirmed_issue_is_planned_synced_warmed_and_started_once():
|
||||
script = f"""
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(CREATE_AND_START))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:387}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:item=>'issue:'+item.repository+':'+item.number+':', add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync: {{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}}, flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:item=>calls.push('start:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const result = flow.complete(issue);
|
||||
flow.complete(issue);
|
||||
process.stdout.write(JSON.stringify({{available:true,result,calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:387:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
"start:387",
|
||||
"announce:Created, added to Today, and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_capture_wires_distinct_create_and_start_intent_into_the_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert '<button id="create-and-start-issue" type="submit">Create & start</button>' in html
|
||||
assert '<script src="static/create-and-start.js"></script>' in html
|
||||
assert "const createAndStart = createCreateAndStart({" in dashboard
|
||||
assert "event.submitter?.id === 'create-and-start-issue'" in dashboard
|
||||
assert "createAndStart.complete(created)" in dashboard
|
||||
assert ".create-issue-actions" in css and "grid-template-columns" in css
|
||||
assert "BASE + 'static/create-and-start.js'" in worker
|
||||
|
||||
|
||||
def test_create_and_start_intent_is_durable_and_has_a_one_tap_relaunch_continuation():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert "completionIntent: 'create-and-start'" in dashboard
|
||||
assert "result.completions || []" in dashboard
|
||||
assert "(result.completions || []).forEach(completion =>" in dashboard
|
||||
assert "issueOutbox.pendingCompletions(activeFlushLogin)" in dashboard
|
||||
assert "issueOutbox.completeIntent(completion.id, activeFlushLogin)" in dashboard
|
||||
assert 'class="draft-continue"' in dashboard
|
||||
assert "Continue created work" in dashboard
|
||||
|
||||
|
||||
def test_create_and_start_stops_before_session_when_today_sync_cannot_be_queued():
|
||||
script = f"""
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls = [];
|
||||
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:388}};
|
||||
const flow = createCreateAndStart({{
|
||||
todayWork: {{limit:5, read:()=>[], identity:()=> 'issue:stackchain/dashboard:388:', add:()=> 'added'}},
|
||||
todaySync: {{enqueue:()=>false, flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'), warm:()=>calls.push('warm'), start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{result:flow.complete(issue), calls}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "sync-unavailable",
|
||||
"calls": ["Created and saved to Today on this device, but account sync is unavailable."],
|
||||
}
|
||||
|
||||
|
||||
def test_create_and_start_can_resume_after_today_sync_admission_recovers():
|
||||
script = f"""
|
||||
const createCreateAndStart = require({json.dumps(str(CREATE_AND_START))});
|
||||
const calls=[];let admitted=false;let added=false;
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:389}};
|
||||
const flow=createCreateAndStart({{
|
||||
todayWork:{{limit:5,read:()=>[],identity:()=> 'issue:stackchain/dashboard:389:',add:()=>{{
|
||||
const outcome=added?'exists':'added';added=true;return outcome;
|
||||
}}}},
|
||||
todaySync:{{enqueue:()=>admitted,flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),warm:()=>calls.push('warm'),start:()=>calls.push('start'),
|
||||
announce:message=>calls.push(message),
|
||||
}});
|
||||
const first=flow.complete(issue);
|
||||
admitted=true;
|
||||
const second=flow.complete(issue);
|
||||
process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == "sync-unavailable"
|
||||
assert output["second"] == "started"
|
||||
assert output["calls"][-5:] == [
|
||||
"refresh", "flush", "warm", "start",
|
||||
"Created, added to Today, and ready to work.",
|
||||
]
|
||||
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
from src.views import FRONTEND_BUILD
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -19,6 +21,23 @@ def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
||||
|
||||
|
||||
async def fresh_grant(client, action: str, target: str) -> str:
|
||||
response = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": action,
|
||||
"target": target,
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["grant"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
@@ -100,6 +119,43 @@ async def test_anonymous_private_request_is_rejected_before_gitea(access_control
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fields(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get(
|
||||
"/",
|
||||
params={
|
||||
"title": "Production crash",
|
||||
"text": "Steps from the mobile app",
|
||||
"url": "https://example.com/incidents/42",
|
||||
"next": "https://evil.example/steal",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
login_query = parse_qs(urlsplit(response.headers["location"]).query)
|
||||
assert login_query == {
|
||||
"continue": [
|
||||
"./?title=Production+crash&text=Steps+from+the+mobile+app&url="
|
||||
"https%3A%2F%2Fexample.com%2Fincidents%2F42"
|
||||
]
|
||||
}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get(
|
||||
"/", params={"title": "x" * 201, "text": "keep me"}
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -198,12 +254,16 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is True
|
||||
payload = response.json()
|
||||
assert payload["authenticated"] is True
|
||||
assert isinstance(payload["expires_at"], int)
|
||||
assert payload["expires_at"] > int(time.time())
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert lookups == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
|
||||
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
@@ -211,14 +271,223 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
|
||||
)
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
payload = response.json()
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"authenticated": True,
|
||||
"csrf_token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
assert payload["authenticated"] is True
|
||||
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
|
||||
assert payload["expires_at"] > int(time.time())
|
||||
assert "correct horse battery staple" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Authentication required"}
|
||||
assert "expires_at" not in response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
merge_calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(repository, number, expected_head_sha):
|
||||
merge_calls.append((repository, number, expected_head_sha))
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
authorized = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/api#7",
|
||||
},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
grant_headers = {**csrf_headers, "X-Step-Up-Grant": authorized.json()["grant"]}
|
||||
merged = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
headers=grant_headers,
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
headers=grant_headers,
|
||||
)
|
||||
|
||||
assert missing.status_code == 428
|
||||
assert missing.json() == {
|
||||
"detail": {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
}
|
||||
assert authorized.status_code == 201
|
||||
assert authorized.json()["expires_in"] == 90
|
||||
assert merged.status_code == 200
|
||||
assert replayed.status_code == 428
|
||||
assert merge_calls == [("stackchain/api", 7, "abc123")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_other_high_impact_routes_require_fresh_authorization_before_mutation(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
close_calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def close(repository, number):
|
||||
close_calls.append((repository, number))
|
||||
return {"number": number, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
remote = next(
|
||||
item
|
||||
for item in (await phone.get("/api/v1/sessions")).json()["devices"]
|
||||
if not item["current"]
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
}
|
||||
closed = await phone.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close", headers=headers
|
||||
)
|
||||
revoked = await phone.delete(
|
||||
f"/api/v1/sessions/{remote['management_id']}", headers=headers
|
||||
)
|
||||
revoked_all = await phone.delete("/api/v1/sessions", headers=headers)
|
||||
laptop_still_active = await laptop.get("/api/v1/session")
|
||||
|
||||
assert [closed.status_code, revoked.status_code, revoked_all.status_code] == [428, 428, 428]
|
||||
assert [closed.json()["detail"]["action"], revoked.json()["detail"]["action"], revoked_all.json()["detail"]["action"]] == [
|
||||
"close_issue", "revoke_device", "revoke_all_sessions"
|
||||
]
|
||||
assert close_calls == []
|
||||
assert laptop_still_active.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_can_review_and_revoke_one_remote_device(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Pixel <script>",
|
||||
},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Work laptop",
|
||||
},
|
||||
)
|
||||
|
||||
listed = await laptop.get("/api/v1/sessions")
|
||||
devices = listed.json()["devices"]
|
||||
phone_device = next(device for device in devices if device["device_label"] == "Pixel <script>")
|
||||
current_device = next(device for device in devices if device["current"])
|
||||
|
||||
missing_csrf = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}"
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_device", phone_device["management_id"]
|
||||
)
|
||||
revoked = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_status = await phone.get("/api/v1/session")
|
||||
phone_page = await phone.get("/", follow_redirects=False)
|
||||
laptop_status = await laptop.get("/api/v1/session")
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert current_device["device_label"] == "Work laptop"
|
||||
assert missing_csrf.status_code == 403
|
||||
assert revoked.json() == {"revoked": True, "current_session": False}
|
||||
assert phone_status.status_code == 401
|
||||
assert phone_status.json() == {
|
||||
"detail": "Authentication required",
|
||||
"code": "session_revoked",
|
||||
}
|
||||
assert phone_page.status_code == 303
|
||||
assert phone_page.headers["location"] == "login?reason=session-revoked"
|
||||
assert laptop_status.status_code == 200
|
||||
assert "session_hash" not in listed.text
|
||||
assert "csrf" not in listed.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_device_labels_are_bounded_at_sign_in(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "x" * 65,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mutation_requires_same_origin_and_session_csrf(access_control, monkeypatch):
|
||||
calls = 0
|
||||
@@ -290,11 +559,13 @@ async def test_sign_out_all_devices_revokes_every_existing_session(access_contro
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
grant = await fresh_grant(phone, "revoke_all_sessions", "all")
|
||||
response = await phone.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_private = await phone.get("/api/v1/background-identity")
|
||||
@@ -341,18 +612,26 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
|
||||
def consume_step_up(self, grant, session_id, *, action, target):
|
||||
return True
|
||||
|
||||
def revoke_all(self):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": csrf,
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
@@ -430,6 +709,171 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.15)
|
||||
return 17
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.01)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 429
|
||||
assert response.headers["retry-after"] == "17"
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def record_failure(self, source):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post("/api/v1/session", json={"access_token": "wrong"})
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 401
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class SlowAttempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def clear(self, source):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 200
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_activation_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class Attempts:
|
||||
def retry_after(self, source):
|
||||
time.sleep(0.02)
|
||||
return 0
|
||||
|
||||
def clear(self, source):
|
||||
return None
|
||||
|
||||
class SlowStore:
|
||||
def activate(self, session_id, expires_at, **kwargs):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: Attempts())
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.13", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
sign_in = asyncio.create_task(
|
||||
client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_in
|
||||
|
||||
assert response.status_code == 200
|
||||
assert any(
|
||||
"stackchain_session=" in value
|
||||
for value in response.headers.get_list("set-cookie")
|
||||
)
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_single_session_revocation_does_not_block_the_event_loop(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple"},
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
|
||||
class SlowStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
time.sleep(0.02)
|
||||
return True
|
||||
|
||||
def revoke(self, session_id):
|
||||
time.sleep(0.15)
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.dashboard_auth, "_session_store", lambda now=None: SlowStore()
|
||||
)
|
||||
sign_out = asyncio.create_task(
|
||||
client.delete(
|
||||
"/api/v1/session",
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
started = time.perf_counter()
|
||||
await asyncio.sleep(0.04)
|
||||
heartbeat_elapsed = time.perf_counter() - started
|
||||
response = await sign_out
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is False
|
||||
assert heartbeat_elapsed < 0.08
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||
access_control, monkeypatch
|
||||
@@ -467,7 +911,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_in_registry_write_failure_issues_no_cookie(access_control, monkeypatch):
|
||||
class BrokenStore:
|
||||
def activate(self, session_id, expires_at):
|
||||
def activate(self, session_id, expires_at, **kwargs):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
@@ -529,9 +973,16 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
|
||||
login = await client.get("/login")
|
||||
manifest = await client.get("/manifest.webmanifest")
|
||||
static = await client.get("/static/session.js")
|
||||
runtime = await client.get("/" + FRONTEND_BUILD.runtime_name)
|
||||
|
||||
assert signed_in.status_code == 200
|
||||
assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200]
|
||||
assert [login.status_code, manifest.status_code, static.status_code, runtime.status_code] == [
|
||||
200,
|
||||
200,
|
||||
200,
|
||||
200,
|
||||
]
|
||||
assert runtime.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -14,11 +14,13 @@ SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [], confirmations: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
|
||||
getItem(key) {{ return this.values.get(key) || null; }},
|
||||
setItem(key, value) {{ this.values.set(key, String(value)); }},
|
||||
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
|
||||
}};
|
||||
const boundary = createSessionBoundary({{
|
||||
@@ -26,16 +28,38 @@ const boundary = createSessionBoundary({{
|
||||
origin: 'https://forge.example',
|
||||
base: '/dashboard/',
|
||||
fetchImpl: async (url, options = {{}}) => {{
|
||||
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
|
||||
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
|
||||
if (state.failFetch) throw new Error('offline');
|
||||
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
|
||||
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
|
||||
}},
|
||||
localStorage: storage,
|
||||
sessionStorage: storage,
|
||||
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
|
||||
indexedDB: {{ deleteDatabase: name => {{
|
||||
state.deletedDatabases.push(name);
|
||||
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
|
||||
setTimeout(() => {{
|
||||
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
|
||||
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
|
||||
}}, 20);
|
||||
return request;
|
||||
}} }},
|
||||
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
|
||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
|
||||
location: {{ assign: value => {{ state.assigned = value; }} }},
|
||||
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
|
||||
MessageChannel: class {{ constructor() {{
|
||||
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
|
||||
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
|
||||
this.port1 = first; this.port2 = second;
|
||||
}} }},
|
||||
location: {{
|
||||
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
|
||||
replace: value => state.replaced.push(value),
|
||||
}},
|
||||
onClearError: error => state.clearErrors.push(error.message),
|
||||
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
|
||||
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
|
||||
confirmAction: message => {{ state.confirmations.push(message); return true; }},
|
||||
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
|
||||
}});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
@@ -43,6 +67,160 @@ const boundary = createSessionBoundary({{
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
let aborted = false;
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => '',
|
||||
origin: 'https://forge.example',
|
||||
base: '/dashboard/',
|
||||
requestTimeoutMs: 10,
|
||||
fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{
|
||||
options.signal?.addEventListener('abort', () => {{ aborted = true; }});
|
||||
setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80);
|
||||
}}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
(async () => {{
|
||||
const started = Date.now();
|
||||
try {{ await boundary.fetch('/dashboard/api/v1/context'); }}
|
||||
catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
name:error.name, message:error.message, method:error.method,
|
||||
outcome:error.outcome, aborted, elapsed:Date.now()-started,
|
||||
}}));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["name"] == "TimeoutError"
|
||||
assert output["message"] == "Request timed out. Try again."
|
||||
assert output["method"] == "GET"
|
||||
assert output["outcome"] == "unknown"
|
||||
assert output["aborted"] is True
|
||||
assert output["elapsed"] < 70
|
||||
|
||||
|
||||
def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const caller = new AbortController();
|
||||
let requestAborted = false;
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
|
||||
fetchImpl: (_url, options = {{}}) => new Promise(() => {{
|
||||
options.signal.addEventListener('abort', () => {{ requestAborted = true; }});
|
||||
}}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
(async () => {{
|
||||
const started = Date.now();
|
||||
const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}});
|
||||
setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10);
|
||||
try {{ await pending; }} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}}));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["name"] == "AbortError"
|
||||
assert output["message"] == "Superseded"
|
||||
assert output["source"] == "caller"
|
||||
assert output["requestAborted"] is True
|
||||
assert output["elapsed"] < 100
|
||||
|
||||
|
||||
def test_request_object_abort_signal_is_composed_with_the_api_deadline():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const caller = new AbortController();
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
|
||||
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
|
||||
}});
|
||||
(async () => {{
|
||||
const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}});
|
||||
const pending = boundary.fetch(request);
|
||||
setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5);
|
||||
try {{ await pending; }} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}}));
|
||||
}}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"name": "AbortError",
|
||||
"message": "Request superseded",
|
||||
"source": "caller",
|
||||
}
|
||||
|
||||
|
||||
def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
|
||||
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }}
|
||||
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"name": "TimeoutError",
|
||||
"message": "Request timed out. Refresh to verify the outcome before retrying.",
|
||||
"method": "PATCH",
|
||||
"safeToRetry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_fresh_authorization_request_uses_the_same_deadline_boundary():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
let calls = 0;
|
||||
const boundary = createSessionBoundary({{
|
||||
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
|
||||
fetchImpl: () => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}}));
|
||||
return new Promise(() => {{}});
|
||||
}},
|
||||
promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}},
|
||||
}});
|
||||
(async () => {{
|
||||
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }}
|
||||
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"name": "TimeoutError",
|
||||
"phase": "fresh-authorization",
|
||||
"calls": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_mutating_same_origin_fetch_receives_csrf_proof():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -54,6 +232,164 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is True
|
||||
assert result["savedExpiry"] == "4102444800"
|
||||
assert result["workerMessages"] == [
|
||||
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
|
||||
]
|
||||
assert result["leaseDelay"] > 0
|
||||
|
||||
|
||||
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
storage.setItem('stackchain.session-expires-at', '1');
|
||||
state.failFetch = true;
|
||||
const valid = await boundary.refreshOfflineLease();
|
||||
state.valid = valid;
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responses = [
|
||||
{status:428, payload:{detail:{code:'step_up_required', action:'merge_pull', target:'stackchain/api#7'}}},
|
||||
{status:201, payload:{grant:'one-time-grant', expires_in:90}},
|
||||
{status:200, payload:{merged:true}},
|
||||
];
|
||||
const original = { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({expected_head_sha:'abc123'}) };
|
||||
const response = await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/pulls/7/merge', original);
|
||||
state.finalStatus = response.status;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["finalStatus"] == 200
|
||||
assert result["prompts"] == [
|
||||
{"action": "merge_pull", "target": "stackchain/api#7"}
|
||||
]
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
"/dashboard/api/v1/fresh-authorization",
|
||||
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
]
|
||||
assert json.loads(result["requests"][1]["body"]) == {
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
assert result["requests"][2]["headers"]["x-step-up-grant"] == "one-time-grant"
|
||||
assert result["requests"][2]["body"] == result["requests"][0]["body"]
|
||||
|
||||
|
||||
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await Promise.all([
|
||||
boundary.fetch('/dashboard/api/v1/live'),
|
||||
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
|
||||
]);
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["expiredAfterDeletion"] is True
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_worker_expiry_message_clears_window_storage_before_expired_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await boundary.fetch('https://untrusted.example/api/private');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == []
|
||||
|
||||
|
||||
def test_sign_out_clears_only_dashboard_private_device_state():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -72,6 +408,21 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.failDeletion = true;
|
||||
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["assigned"] == ""
|
||||
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
|
||||
assert result["signOutError"] == "Could not clear private queued work from this device."
|
||||
|
||||
|
||||
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
||||
@@ -94,6 +445,30 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_active_devices_can_be_loaded_and_one_remote_device_revoked():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {devices:[
|
||||
{management_id:'remote-id-123456789', device_label:'Pixel <script>', created_at:1000, expires_at:2000, current:false},
|
||||
{management_id:'current-id-12345678', device_label:'Work laptop', created_at:1100, expires_at:2100, current:true},
|
||||
]};
|
||||
const devices = await boundary.listActiveDevices();
|
||||
await boundary.revokeActiveDevice(devices[0]);
|
||||
state.devices = devices;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["devices"][0]["device_label"] == "Pixel <script>"
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/sessions"
|
||||
assert result["requests"][1]["url"] == (
|
||||
"/dashboard/api/v1/sessions/remote-id-123456789"
|
||||
)
|
||||
assert result["requests"][1]["method"] == "DELETE"
|
||||
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["confirmations"] == ["Sign out Pixel <script>?"]
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -113,4 +488,7 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '<button id="active-devices" type="button">Active devices</button>' in html
|
||||
assert 'id="active-devices-sheet"' in html
|
||||
assert 'aria-label="Active devices"' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
+41
-2
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
||||
@@ -121,6 +121,42 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||
id:'continue-1',operationId:'continue-1',repository:'stackchain/dashboard',
|
||||
title:'Resume this issue',body:'',ownerLogin:'timmy',status:'completion',queuedAt:200,
|
||||
completionIntent:'create-and-start',deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume this issue'}}
|
||||
}}]}})]]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null}};
|
||||
const item = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["status"] == "completion"
|
||||
assert output["label"] == "Created · ready to start"
|
||||
assert output["continuation"] is True
|
||||
assert output["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:7,body:'Possibly posted',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',error:'Verify it was not posted before retrying.',queuedAt:200}}
|
||||
]}})]]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["delivery_state"] == "uncertain"
|
||||
assert output["label"] == "Verify delivery"
|
||||
|
||||
|
||||
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -159,7 +195,10 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -6,13 +6,14 @@ import pytest
|
||||
|
||||
from src import main
|
||||
from src import gitea_proxy
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
@@ -25,14 +26,14 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
|
||||
|
||||
|
||||
def test_event_stream_reports_when_activity_was_refreshed():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'id="gitea-events-status"' in html
|
||||
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
|
||||
|
||||
|
||||
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "setEventStreamStatus('Update failed · showing last activity')" in html
|
||||
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import gzip
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
PAGE_SCRIPT = re.compile(r'<script src="(static/[^"]+\.js)"></script>')
|
||||
|
||||
|
||||
def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
second = build_frontend(FRONTEND)
|
||||
|
||||
assert first.runtime_name == second.runtime_name
|
||||
assert first.runtime_bytes == second.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
assert changed.runtime_name != first.runtime_name
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
assert int(response.headers["content-length"]) <= 100 * 1024
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
|
||||
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": accept_encoding},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "content-encoding" not in response.headers
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
|
||||
stylesheet = await client.get(
|
||||
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert page.headers["content-encoding"] == "gzip"
|
||||
assert page.headers["cache-control"] == "no-cache"
|
||||
assert page.headers["content-security-policy"]
|
||||
assert main.FRONTEND_BUILD.runtime_name in page.text
|
||||
assert stylesheet.headers["content-encoding"] == "gzip"
|
||||
assert "mobile-task-dock" in stylesheet.text
|
||||
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
|
||||
assert "content-encoding" not in health.headers
|
||||
|
||||
|
||||
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
assert f"BASE + '{build.runtime_name}'" in build.service_worker_source
|
||||
for source in build.page_sources:
|
||||
if source == "static/background-issue-sync.js":
|
||||
continue
|
||||
assert f"BASE + '{source}'" not in build.service_worker_source
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
html = await dashboard()
|
||||
bundle = await runtime_bundle(build.runtime_digest)
|
||||
worker = await service_worker()
|
||||
|
||||
assert build.runtime_name in html
|
||||
assert bundle.body == build.runtime_bytes
|
||||
assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert worker.headers["cache-control"] == "no-cache"
|
||||
assert build.runtime_name.encode() in worker.body
|
||||
@@ -133,7 +133,10 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/issues/7"):
|
||||
return httpx.Response(200, json={"body": "Deploy fails after **three** retries."})
|
||||
return httpx.Response(200, json={
|
||||
"number": 7, "state": "open", "assignees": [],
|
||||
"body": "Deploy fails after **three** retries.",
|
||||
})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
@@ -178,6 +181,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"created_at": "2026-08-06T12:30:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-9",
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
|
||||
@@ -71,3 +71,21 @@ def test_independent_connections_atomically_reserve_one_operation(tmp_path):
|
||||
states = list(executor.map(reserve, ledgers))
|
||||
|
||||
assert sorted(states) == ["pending", "reserved"]
|
||||
|
||||
|
||||
def test_stale_pending_reservation_becomes_uncertain_without_consuming_capacity(tmp_path):
|
||||
now = [1_000.0]
|
||||
ledger = IdempotencyLedger(
|
||||
tmp_path / "orphaned.sqlite3",
|
||||
ttl_seconds=10,
|
||||
max_entries=1,
|
||||
clock=lambda: now[0],
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "Verify first")
|
||||
|
||||
assert ledger.reserve("orphan", fingerprint).state == "reserved"
|
||||
now[0] += 11
|
||||
|
||||
assert ledger.reserve("orphan", fingerprint).state == "uncertain"
|
||||
assert ledger.reserve("unrelated", ("issue-comment", "stackchain/api", 8, "Continue")).state == "reserved"
|
||||
assert ledger.reserve("orphan", ("issue-comment", "stackchain/api", 7, "Changed")).state == "conflict"
|
||||
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
|
||||
|
||||
|
||||
def run_install_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createInstallApp = require({json.dumps(str(INSTALL_APP))});
|
||||
class FakeTarget {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
|
||||
dispatch(name, event = {{}}) {{ for (const callback of this.listeners[name] || []) callback(event); }}
|
||||
}}
|
||||
class FakeButton extends FakeTarget {{
|
||||
constructor() {{ super(); this.disabled = false; }}
|
||||
click() {{ this.dispatch('click', {{currentTarget:this}}); }}
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_native_install_prompt_is_revealed_by_eligibility_and_invoked_once():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton();
|
||||
const dismiss = new FakeButton();
|
||||
const guidance = {hidden:true};
|
||||
const status = {textContent:''};
|
||||
let promptCalls = 0;
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {
|
||||
preventDefault(){},
|
||||
prompt:async () => { promptCalls += 1; },
|
||||
userChoice:Promise.resolve({outcome:'accepted'}),
|
||||
});
|
||||
const shown = !card.hidden;
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({shown, promptCalls, hidden:card.hidden, status:status.textContent}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"shown": True,
|
||||
"promptCalls": 1,
|
||||
"hidden": True,
|
||||
"status": "Stackchain was added to your device.",
|
||||
}
|
||||
|
||||
|
||||
def test_declined_native_install_hides_the_spent_choice_and_announces_it():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton();
|
||||
const status = {textContent:''};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {
|
||||
preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'dismissed'}),
|
||||
});
|
||||
install.click();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({hidden:card.hidden, disabled:install.disabled, status:status.textContent}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"hidden": True,
|
||||
"disabled": False,
|
||||
"status": "Installation was not completed.",
|
||||
}
|
||||
|
||||
|
||||
def test_ios_safari_gets_manual_add_to_home_screen_guidance():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const install = new FakeButton(); install.hidden = false;
|
||||
const dismiss = new FakeButton();
|
||||
const guidance = {hidden:true};
|
||||
const status = {textContent:''};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:install, dismissButton:dismiss,
|
||||
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
app.start();
|
||||
process.stdout.write(JSON.stringify({cardHidden:card.hidden, installHidden:install.hidden, guidanceHidden:guidance.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"cardHidden": False,
|
||||
"installHidden": True,
|
||||
"guidanceHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_standalone_launch_never_promotes_installation():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>true, isIosSafari:()=>true,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
process.stdout.write(JSON.stringify({hidden:card.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"hidden": True}
|
||||
|
||||
|
||||
def test_dismissal_is_remembered_and_suppresses_later_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key, value) => values.set(key, value)};
|
||||
const firstWindow = new FakeTarget();
|
||||
const firstCard = {hidden:true};
|
||||
const firstDismiss = new FakeButton();
|
||||
const first = createInstallApp({
|
||||
window:firstWindow, card:firstCard, installButton:new FakeButton(), dismissButton:firstDismiss,
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
first.start();
|
||||
firstDismiss.click();
|
||||
const secondCard = {hidden:true};
|
||||
const second = createInstallApp({
|
||||
window:new FakeTarget(), card:secondCard, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage,
|
||||
isStandalone:()=>false, isIosSafari:()=>true,
|
||||
});
|
||||
second.start();
|
||||
process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:values.size, secondHidden:secondCard.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
|
||||
|
||||
|
||||
def test_appinstalled_hides_an_visible_native_promotion():
|
||||
result = run_install_scenario(
|
||||
"""
|
||||
const windowTarget = new FakeTarget();
|
||||
const card = {hidden:true};
|
||||
const app = createInstallApp({
|
||||
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
|
||||
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
|
||||
isStandalone:()=>false, isIosSafari:()=>false,
|
||||
});
|
||||
app.start();
|
||||
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
|
||||
const shown = !card.hidden;
|
||||
windowTarget.dispatch('appinstalled');
|
||||
process.stdout.write(JSON.stringify({shown, hidden:card.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"shown": True, "hidden": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_touch_safe_install_promotion():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="install-app-card"' in html
|
||||
assert 'id="install-app"' in html
|
||||
assert 'id="dismiss-install-app"' in html
|
||||
assert 'id="install-app-guidance"' in html
|
||||
assert 'Share, then choose <strong>Add to Home Screen</strong>' in html
|
||||
assert '.install-app-card button { min-height:44px;' in html
|
||||
assert '<script src="static/install-app.js"></script>' in html
|
||||
assert 'createInstallApp({' in html
|
||||
assert "window.matchMedia('(display-mode: standalone)').matches" in html
|
||||
assert "navigator.standalone === true" in html
|
||||
assert "isIosSafari" in html
|
||||
+160
-1
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
(item for item in repositories if item.get("full_name") == repository), None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||
requests = []
|
||||
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -373,6 +388,118 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append((request.url.path, dict(request.url.params)))
|
||||
if request.url.path.endswith("/user/repos"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
headers={"X-Total-Count": "51"},
|
||||
)
|
||||
if request.url.path.endswith("/repos/stackchain/later"):
|
||||
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
page = await gitea_proxy.repo_page(page=2, limit=50)
|
||||
first_page = await gitea_proxy.repos()
|
||||
accessible = await gitea_proxy.repository_access("stackchain/later")
|
||||
missing = await gitea_proxy.repository_access("stackchain/missing")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert page == {
|
||||
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
"page": 2,
|
||||
"total": 51,
|
||||
"has_more": False,
|
||||
}
|
||||
assert accessible["full_name"] == "stackchain/later"
|
||||
assert missing is None
|
||||
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
|
||||
assert calls == [
|
||||
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
|
||||
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
|
||||
("/api/v1/repos/stackchain/later", {}),
|
||||
("/api/v1/repos/stackchain/missing", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
access_calls = []
|
||||
create_calls = []
|
||||
|
||||
async def page(page, limit):
|
||||
assert (page, limit) == (2, 50)
|
||||
return {
|
||||
"items": [{
|
||||
"id": 51, "name": "later", "full_name": "stackchain/later",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/later",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
"page": 2, "total": 51, "has_more": False,
|
||||
}
|
||||
|
||||
async def access(repository):
|
||||
access_calls.append(repository)
|
||||
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
create_calls.append((repository, title, assignee))
|
||||
return {
|
||||
"id": 403, "number": 403, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/later/issues/403",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repositories?page=2&limit=50")
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
|
||||
assert listed.json()["has_more"] is False
|
||||
assert created.status_code == 201
|
||||
assert access_calls == ["stackchain/later"]
|
||||
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
|
||||
|
||||
|
||||
def test_context_exposes_truthful_repository_pagination():
|
||||
repositories = gitea_proxy.RepositoryItems(
|
||||
[{
|
||||
"id": 1, "name": "api", "full_name": "stackchain/api",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/api",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
{"page": 1, "total": 51, "has_more": True},
|
||||
)
|
||||
|
||||
payload = main._context_payload(
|
||||
{"id": 2, "login": "timmy"}, repositories, [], []
|
||||
)
|
||||
|
||||
assert payload["repository_pagination"] == {
|
||||
"page": 1, "total": 51, "has_more": True
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||
calls = []
|
||||
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-concurrent"}
|
||||
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-create-201"}
|
||||
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
|
||||
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
|
||||
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
|
||||
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -915,7 +1074,7 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
|
||||
assert response.status_code == 409
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
|
||||
"error": "This issue was already claimed or is no longer open."
|
||||
}
|
||||
|
||||
|
||||
|
||||
+113
-3
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
|
||||
@@ -68,7 +68,57 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
|
||||
]
|
||||
assert [item["operationId"] for item in output["items"]] == ["op-1", "op-2"]
|
||||
assert all(item["status"] == "queued" for item in output["items"])
|
||||
assert output["stored"]["version"] == 2
|
||||
assert output["stored"]["version"] == 3
|
||||
|
||||
|
||||
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'create-start-1',
|
||||
fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}}),
|
||||
}});
|
||||
outbox.enqueue({{
|
||||
repository:'stackchain/dashboard',title:'Resume me',body:'',completionIntent:'create-and-start'
|
||||
}});
|
||||
const reloaded = createIssueOutbox({{
|
||||
storage, fetchJson:async () => ({{repository:'stackchain/dashboard',number:389,title:'Resume me'}})
|
||||
}});
|
||||
reloaded.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
|
||||
persisted:JSON.parse(values.get('stackchain.issue-outbox.v1')),
|
||||
result,
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["persisted"]["version"] == 3
|
||||
completion = output["result"]["completions"][0]
|
||||
assert completion["intent"] == "create-and-start"
|
||||
assert completion["ownerLogin"] == "timmy"
|
||||
assert completion["operationId"] == "create-start-1"
|
||||
assert completion["issue"]["number"] == 389
|
||||
|
||||
|
||||
def test_editing_a_queued_issue_updates_its_completion_intent_without_rekeying_delivery():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();let sequence=0;
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox=createIssueOutbox({{
|
||||
storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'intent-' + (++sequence)
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Work',body:''}});
|
||||
const started=outbox.update(queued.id,{{...queued,completionIntent:'create-and-start'}});
|
||||
const ordinary=outbox.update(queued.id,{{...started,completionIntent:undefined}});
|
||||
process.stdout.write(JSON.stringify({{started,ordinary}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["started"]["completionIntent"] == "create-and-start"
|
||||
assert "completionIntent" not in output["ordinary"]
|
||||
assert output["started"]["operationId"] == output["ordinary"]["operationId"] == "intent-1"
|
||||
|
||||
|
||||
def test_issue_outbox_flushes_sequentially_and_keeps_transient_failures_queued():
|
||||
@@ -139,6 +189,34 @@ outbox.flush('timmy').then(async () => {{
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rekeys_only_after_user_retries_uncertain_delivery():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let sequence=0; const calls=[];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'operation-' + (++sequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
const key=options.headers['Idempotency-Key']; calls.push(key);
|
||||
if (key === 'operation-1') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
|
||||
return {{number:319}};
|
||||
}},
|
||||
}});
|
||||
const item=outbox.enqueue({{repository:'o/r',title:'Possibly sent',body:'Check first'}});
|
||||
(async()=>{{
|
||||
await outbox.flush('timmy'); const attention=outbox.list()[0];
|
||||
await outbox.retry(item.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["deliveryState"] == "uncertain"
|
||||
assert output["calls"] == ["operation-1", "operation-2"]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -356,13 +434,45 @@ process.stdout.write(JSON.stringify(outbox.list()));
|
||||
]
|
||||
|
||||
|
||||
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{
|
||||
id:'resume',operationId:'resume',ownerLogin:'timmy',status:'queued',
|
||||
repository:'stackchain/dashboard',title:'Resume',body:'',completionIntent:'create-and-start'
|
||||
}}]}}));
|
||||
const outbox=createIssueOutbox({{storage}});
|
||||
outbox.reconcileBackground([{{
|
||||
id:'resume',status:'sent',ownerLogin:'timmy',completionIntent:'create-and-start',
|
||||
deliveredIssue:{{repository:'stackchain/dashboard',number:389,title:'Resume'}}
|
||||
}}]);
|
||||
const blocked=outbox.pendingCompletions('alexander');
|
||||
const pending=outbox.pendingCompletions('timmy');
|
||||
const retained=outbox.list();
|
||||
outbox.completeIntent('resume','timmy');
|
||||
process.stdout.write(JSON.stringify({{blocked,pending,retained,remaining:outbox.list()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["blocked"] == []
|
||||
assert output["pending"] == [{
|
||||
"id": "resume", "intent": "create-and-start", "ownerLogin": "timmy",
|
||||
"operationId": "resume",
|
||||
"issue": {"repository": "stackchain/dashboard", "number": 389, "title": "Resume"},
|
||||
}]
|
||||
assert output["retained"][0]["status"] == "completion"
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/issue-outbox.js"></script>' in html
|
||||
assert "const issueOutbox = createIssueOutbox({" in html
|
||||
assert "await issueOutbox.enqueueDurably(captureDraft)" in html
|
||||
assert "await issueOutbox.enqueueDurably(durableDraft)" in html
|
||||
assert "Saving for background delivery…" in html
|
||||
assert "Saved for next launch; background delivery unavailable." in html
|
||||
assert "throw new Error('Background Sync unavailable')" in html
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_exact_local_time_is_validated_and_converted_to_an_instant():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const picker = createLaterPicker({{
|
||||
now: () => new Date(2026, 7, 9, 14, 0, 0),
|
||||
}});
|
||||
const valid = picker.parse('2026-08-09T16:30');
|
||||
const past = picker.parse('2026-08-09T13:59');
|
||||
const invalid = picker.parse('2026-02-30T12:00');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: {{ok:valid.ok, local:[valid.value.getFullYear(), valid.value.getMonth() + 1, valid.value.getDate(), valid.value.getHours(), valid.value.getMinutes()]}},
|
||||
past, invalid,
|
||||
}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["valid"] == {"ok": True, "local": [2026, 8, 9, 16, 30]}
|
||||
assert output["past"] == {
|
||||
"ok": False,
|
||||
"message": "Choose a future date and time.",
|
||||
}
|
||||
assert output["invalid"] == {
|
||||
"ok": False,
|
||||
"message": "Choose a valid local date and time.",
|
||||
}
|
||||
|
||||
|
||||
def test_picker_uses_history_for_cancel_and_confirms_only_valid_future_time():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
const states = [];
|
||||
const confirmed = [];
|
||||
let focused = 0;
|
||||
const history = {{
|
||||
state: {{workRoute:'issue'}},
|
||||
pushState(state) {{ this.state = state; states.push(['push', state.laterPicker]); }},
|
||||
back() {{ states.push(['back']); const previous = {{workRoute:'issue'}}; this.state = previous; listeners.popstate({{state:previous}}); }},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now: () => new Date(2026, 7, 9, 14, 0, 0), history,
|
||||
eventTarget: {{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onState: state => states.push([state.open, state.message || '']),
|
||||
onConfirm: (item, value, context) => {{ confirmed.push([item.number, value.toISOString(), context]); return true; }},
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}}, {{focus:() => {{ focused += 1; }}}}, 'detail');
|
||||
const invalid = picker.submit('2026-08-09T13:00');
|
||||
const valid = picker.submit('2026-08-09T16:30');
|
||||
picker.submit('2026-08-09T17:30');
|
||||
process.stdout.write(JSON.stringify({{invalid, valid, confirmed, focused, current:picker.current(), states}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["invalid"] is False
|
||||
assert output["valid"] is True
|
||||
assert output["confirmed"] == [[17, "2026-08-09T16:30:00.000Z", "detail"]]
|
||||
assert output["focused"] == 1
|
||||
assert output["current"] is False
|
||||
assert [False, "Choose a future date and time."] not in output["states"]
|
||||
assert [True, "Choose a future date and time."] in output["states"]
|
||||
assert ["back"] in output["states"]
|
||||
|
||||
|
||||
def test_picker_runs_detail_cleanup_only_after_its_history_entry_closes():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
const order = [];
|
||||
const history = {{
|
||||
state: {{workRoute:'issue'}},
|
||||
pushState(state) {{ this.state = state; }},
|
||||
back() {{
|
||||
order.push('picker-back');
|
||||
this.state = {{workRoute:'issue'}};
|
||||
listeners.popstate({{state:this.state}});
|
||||
}},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onConfirm:() => () => order.push('detail-close'),
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}}, null, 'detail');
|
||||
const saved = picker.submit('2026-08-09T16:30');
|
||||
process.stdout.write(JSON.stringify({{saved, order}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"saved": True, "order": ["picker-back", "detail-close"]}
|
||||
|
||||
|
||||
def test_picker_ignores_a_second_submit_while_browser_back_is_pending():
|
||||
script = f"""
|
||||
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||
const listeners = {{}};
|
||||
let popstate;
|
||||
let confirmations = 0;
|
||||
const history = {{
|
||||
state:null,
|
||||
pushState(state) {{ this.state = state; }},
|
||||
back() {{ popstate = () => {{ this.state = {{}}; listeners.popstate({{state:this.state}}); }}; }},
|
||||
}};
|
||||
const picker = createLaterPicker({{
|
||||
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||
onConfirm:() => {{ confirmations += 1; return true; }},
|
||||
}});
|
||||
picker.start();
|
||||
picker.open({{number:17}});
|
||||
const first = picker.submit('2026-08-09T16:30');
|
||||
const second = picker.submit('2026-08-09T16:30');
|
||||
popstate();
|
||||
process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||
@@ -0,0 +1,277 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.later_store import LaterStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
class BusyStore:
|
||||
def apply(self, *_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is busy")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
|
||||
payload = main.LaterOperation(
|
||||
operation_id="retry-me",
|
||||
action="defer",
|
||||
item_id="issue:r:1:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.update_later_plan(payload)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.headers == {"Retry-After": "1"}
|
||||
|
||||
|
||||
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path)
|
||||
|
||||
deferred = store.apply(
|
||||
"Timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
duplicate = store.apply(
|
||||
"timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-11T09:00:00.000Z",
|
||||
)
|
||||
|
||||
assert duplicate == deferred == {
|
||||
"revision": 1,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||
},
|
||||
}
|
||||
assert LaterStore(path).get("timmy") == deferred
|
||||
assert store.get("alexander") == {"revision": 0, "records": {}}
|
||||
|
||||
assert store.apply(
|
||||
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
operations = [
|
||||
{"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
||||
{"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
|
||||
]
|
||||
|
||||
result = store.apply_batch("timmy", operations)
|
||||
assert result == {
|
||||
"revision": 2,
|
||||
"records": {},
|
||||
"accepted_operation_ids": ["first", "second"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
replay = store.apply_batch("timmy", operations)
|
||||
assert replay["revision"] == 2
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
assert replay["duplicate_operation_ids"] == ["first", "second"]
|
||||
|
||||
|
||||
def test_batch_rejects_stale_same_item_intent_without_blocking_other_items(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
first = store.apply_batch("timmy", [{
|
||||
"operation_id": "newer-device",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-12T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
}])
|
||||
|
||||
replay = store.apply_batch("timmy", [
|
||||
{
|
||||
"operation_id": "stale-device",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
},
|
||||
{
|
||||
"operation_id": "unrelated-item",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-11T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
},
|
||||
])
|
||||
|
||||
assert first["revision"] == 1
|
||||
assert replay == {
|
||||
"revision": 2,
|
||||
"records": {
|
||||
"issue:r:1:": "2026-08-12T09:00:00.000Z",
|
||||
"issue:r:2:": "2026-08-11T09:00:00.000Z",
|
||||
},
|
||||
"accepted_operation_ids": ["unrelated-item"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [
|
||||
{"operation_id": "stale-device", "reason": "stale_intent"}
|
||||
],
|
||||
}
|
||||
duplicate = store.apply_batch("timmy", [{
|
||||
"operation_id": "stale-device",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"base_revision": 2,
|
||||
}])
|
||||
assert duplicate["duplicate_operation_ids"] == ["stale-device"]
|
||||
assert duplicate["revision"] == 2
|
||||
assert duplicate["records"] == replay["records"]
|
||||
|
||||
|
||||
def test_later_receipts_are_bounded_per_account_and_existing_schema_migrates(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE later_operations (login TEXT NOT NULL, operation_id TEXT NOT NULL, "
|
||||
"PRIMARY KEY (login, operation_id))"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES ('timmy', 'legacy')"
|
||||
)
|
||||
|
||||
store = LaterStore(path, operation_limit=2, clock=lambda: 1_000)
|
||||
for index in range(4):
|
||||
store.apply(
|
||||
"timmy", f"op-{index}", "defer", f"issue:r:{index}:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
store.apply(
|
||||
"alexander", "other", "defer", "issue:r:99:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(later_operations)")}
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy' ORDER BY rowid"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert "created_at" in columns
|
||||
assert timmy == [("op-2",), ("op-3",)]
|
||||
assert alexander == [("other",)]
|
||||
|
||||
|
||||
def test_later_receipt_age_pruning_is_account_scoped(tmp_path):
|
||||
clock = [1_000.0]
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, operation_retention_seconds=60, clock=lambda: clock[0])
|
||||
store.apply("timmy", "old", "restore", "issue:r:1:")
|
||||
store.apply("alexander", "other-old", "restore", "issue:r:2:")
|
||||
clock[0] += 61
|
||||
store.apply("timmy", "fresh", "restore", "issue:r:3:")
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
timmy = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'timmy'"
|
||||
).fetchall()
|
||||
alexander = connection.execute(
|
||||
"SELECT operation_id FROM later_operations WHERE login = 'alexander'"
|
||||
).fetchall()
|
||||
|
||||
assert timmy == [("fresh",)]
|
||||
assert alexander == [("other-old",)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LATER_DB", str(tmp_path / "later.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
forbidden = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "defer",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
},
|
||||
)
|
||||
changed = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operations": [
|
||||
{"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
||||
{"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
|
||||
],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
stale = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operations": [{
|
||||
"operation_id": "offline-stale",
|
||||
"action": "restore",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"base_revision": 0,
|
||||
}],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/later")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert changed.status_code == 200
|
||||
assert changed.json() == {
|
||||
"revision": 2,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
},
|
||||
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert stale.json()["rejected_operations"] == [
|
||||
{"operation_id": "offline-stale", "reason": "stale_intent"}
|
||||
]
|
||||
assert stale.json()["revision"] == 2
|
||||
assert fetched.json() == {"revision": 2, "records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
}}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,351 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const createCoordinator=require({json.dumps(str(COORDINATOR))});
|
||||
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
|
||||
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
|
||||
let sequence=0,releaseFirst;const delivered=[];
|
||||
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
||||
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
|
||||
assert len(result["delivered"]) == 2
|
||||
assert result["pending"] == []
|
||||
assert not any(".operation." in key for key in result["keys"])
|
||||
|
||||
|
||||
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const prefix='stackchain.later-sync.v1.timmy.operation.';
|
||||
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
|
||||
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
|
||||
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(...JSON.parse(options.body).operations);return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
|
||||
assert result["pending"] == []
|
||||
assert not any(".operation." in key for key in result["keys"])
|
||||
|
||||
|
||||
def test_offline_deferral_replays_once_and_adopts_server_records():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values = new Map();
|
||||
const requests = [];
|
||||
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
|
||||
const sync = createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
|
||||
fetchJson:async (url,options={{}})=>{{
|
||||
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
|
||||
if (!options.method) return remote;
|
||||
const operation=JSON.parse(options.body).operations[0];
|
||||
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
|
||||
return remote;
|
||||
}},
|
||||
onRemoteRecords:records=>{{globalThis.records=records}},
|
||||
onStatus:status=>{{globalThis.status=status}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
|
||||
}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"api/v1/later",
|
||||
"api/v1/later",
|
||||
]
|
||||
assert result["requests"][0]["body"] == {"operations": [{
|
||||
"operation_id": "offline-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 0,
|
||||
}]}
|
||||
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const statuses=[];const requests=[];
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'old-later',
|
||||
now:()=>1_800_000_000_000,maxOfflineMs:1000,
|
||||
fetchJson:async(_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:4,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:(state,detail={{}})=>statuses.push([state,detail.count||0])}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('restore','issue:r:2:');
|
||||
const key=[...values.keys()].find(value=>value.includes('.operation.'));
|
||||
const record=JSON.parse(values.get(key));record.queued_at=1_799_999_000_000;values.set(key,JSON.stringify(record));
|
||||
await sync.flush();process.stdout.write(JSON.stringify({{requests,statuses,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["requests"] == ["GET", "GET"]
|
||||
assert ["expired", 1] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["expired", 1]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_stale_receipt_adopts_server_truth_and_reports_another_device_conflict():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];const statuses=[];const adopted=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createLaterSync({{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'offline-old',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
if(!options.method)return {{revision:3,records:{{'issue:r:1:':'2026-08-12T09:00:00.000Z'}}}};
|
||||
const body=JSON.parse(options.body);requests.push(body);
|
||||
return {{revision:4,records:{{'issue:r:1:':'2026-08-13T09:00:00.000Z'}},accepted_operation_ids:[],duplicate_operation_ids:[],rejected_operations:[{{operation_id:'offline-old',reason:'stale_intent'}}]}};
|
||||
}},onRemoteRecords:records=>adopted.push(records),onStatus:(state,detail={{}})=>statuses.push([state,detail]),
|
||||
}});
|
||||
(async()=>{{await sync.flush();sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{requests,statuses,adopted,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "offline-old",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:1:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
"base_revision": 3,
|
||||
}]}]
|
||||
assert result["statuses"][-1] == ["conflict", {"count": 1}]
|
||||
assert result["adopted"][-1] == {
|
||||
"issue:r:1:": "2026-08-13T09:00:00.000Z"
|
||||
}
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,records:{{}},accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result=run_node(script)
|
||||
assert len(result["requests"]) == 1
|
||||
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"pending": [
|
||||
{
|
||||
"operation_id": "op-2",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": None,
|
||||
"base_revision": 0,
|
||||
}
|
||||
],
|
||||
"status": "retrying",
|
||||
}
|
||||
|
||||
|
||||
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
|
||||
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{
|
||||
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
|
||||
const scheduledBeforeSwitch=timers.length;
|
||||
login='alexander'; await timers[0].callback();
|
||||
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
|
||||
}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"scheduledBeforeSwitch": 1,
|
||||
"timers": 1,
|
||||
"requests": ["PATCH"],
|
||||
"pendingForAlexander": [],
|
||||
}
|
||||
|
||||
|
||||
def test_change_queued_during_delivery_is_drained_before_flush_settles():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return {{revision:0,records:{{}}}};
|
||||
const operation=JSON.parse(options.body).operations[0]; actions.push(operation.action);
|
||||
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
|
||||
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
|
||||
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
|
||||
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
|
||||
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actions": ["defer", "restore"],
|
||||
"pending": [],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
|
||||
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const listeners=[];
|
||||
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
|
||||
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
|
||||
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
|
||||
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
|
||||
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
|
||||
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
|
||||
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
|
||||
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
|
||||
|
||||
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
|
||||
const first=sync.migrate(records),second=sync.migrate(records);
|
||||
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
|
||||
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"requests": ["PATCH"],
|
||||
"pending": [],
|
||||
}
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-08T12:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
|
||||
onExpire:ids=>expired.push(ids),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-08T16:00:00Z'));
|
||||
work.restore(item);
|
||||
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
|
||||
const remote=work.partition([item]);
|
||||
clock=new Date('2026-08-09T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["changes"] == [
|
||||
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
|
||||
["restore", "issue:stackchain/api:17:", None],
|
||||
]
|
||||
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
|
||||
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-sync.js"></script>' in html
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
assert "Later saved to account." in html
|
||||
assert "Another device changed this Later item" in html
|
||||
assert "Today edit expired after 30 days offline" in html
|
||||
assert "Later edit expired after 30 days offline" in html
|
||||
|
||||
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
@@ -90,9 +91,9 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
assert result["revisions"] == {
|
||||
"context": 1, "events": 1, "notifications": 1
|
||||
}
|
||||
assert set(result["revisions"]) == {"context", "events", "notifications"}
|
||||
assert len(set(result["revisions"].values())) == 1
|
||||
assert result["revisions"]["context"].endswith(".1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -129,6 +130,56 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
monkeypatch.setattr(main, "_live_revision_generation", "worker-a")
|
||||
|
||||
worker_a = payload(await main.live_snapshot())
|
||||
worker_a_token = worker_a["revisions"]["context"]
|
||||
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
monkeypatch.setattr(main, "_live_revision_generation", "worker-b")
|
||||
|
||||
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
|
||||
|
||||
assert worker_a_token == "worker-a.1"
|
||||
assert worker_b["revisions"]["context"] == "worker-b.1"
|
||||
assert worker_b["context"]["user"]["login"] == "timmy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_rejects_malformed_revision_before_upstream_work(monkeypatch):
|
||||
async def unexpected_user():
|
||||
raise AssertionError("malformed revision reached upstream work")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", unexpected_user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/api/v1/live?context_revision=not-a-token")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_keeps_fresh_context_when_activity_fails(monkeypatch):
|
||||
async def user():
|
||||
|
||||
@@ -11,6 +11,101 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
LOGIN_JS = ROOT / "frontend" / "login.js"
|
||||
|
||||
|
||||
def test_expired_session_reason_explains_preserved_private_work():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
controller.showReason('session-expired');
|
||||
const expired = status.textContent;
|
||||
controller.showReason('https://evil.example/redirect');
|
||||
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["expired"] == (
|
||||
"Your session expired. Private drafts remain on this device. "
|
||||
"Sign in to continue."
|
||||
)
|
||||
assert state["ignored"] == state["expired"]
|
||||
|
||||
|
||||
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{cleared:false, snapshots:[]}};
|
||||
const status = {{textContent:''}};
|
||||
const button = {{disabled:false}};
|
||||
const controller = createLoginController({{
|
||||
form:{{reset:()=>{{}}}}, status, button,
|
||||
fetchImpl:async()=>new Response('{{}}',{{status:200}}),
|
||||
location:{{replace:()=>{{}}}},
|
||||
clearPrivateDeviceData:async()=>{{
|
||||
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
|
||||
await new Promise(resolve=>setTimeout(resolve,10));
|
||||
state.cleared=true;
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const pending=controller.showReason('session-revoked');
|
||||
state.snapshots.push({{status:status.textContent,disabled:button.disabled}});
|
||||
await pending;
|
||||
state.final={{status:status.textContent,disabled:button.disabled,cleared:state.cleared}};
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert all(snapshot["disabled"] for snapshot in state["snapshots"])
|
||||
assert state["final"] == {
|
||||
"status": "This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.",
|
||||
"disabled": False,
|
||||
"cleared": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_loads_private_data_purger_before_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/private-device-data.js"></script>' in html
|
||||
assert html.index('static/private-device-data.js') < html.index('static/login.js')
|
||||
|
||||
|
||||
def test_revoked_session_does_not_claim_success_when_purger_is_unavailable():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status={{textContent:''}};
|
||||
const button={{disabled:false}};
|
||||
const controller=createLoginController({{
|
||||
form:{{reset:()=>{{}}}},status,button,
|
||||
fetchImpl:async()=>new Response('{{}}',{{status:200}}),location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.showReason('session-revoked');
|
||||
process.stdout.write(JSON.stringify({{status:status.textContent,disabled:button.disabled}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"status": "This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.",
|
||||
"disabled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_rate_limited_login_disables_submit_and_counts_down():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
@@ -52,9 +147,152 @@ const controller = createLoginController({{
|
||||
}
|
||||
|
||||
|
||||
def test_successful_login_resumes_valid_shared_capture_continuation():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const state = {{ replaced: null }};
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => state.replaced = value }},
|
||||
continuation: './?title=Production+crash&url=https%3A%2F%2Fexample.com',
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('operator-token');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout)["replaced"] == (
|
||||
"./?title=Production+crash&url=https%3A%2F%2Fexample.com"
|
||||
)
|
||||
|
||||
|
||||
def test_login_sends_a_bounded_device_label_with_the_access_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
let request = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async (url, options) => {{ request = {{url, body: JSON.parse(options.body)}}; return new Response('{{}}', {{ status: 200 }}); }},
|
||||
location: {{ replace: () => {{}} }},
|
||||
}});
|
||||
(async () => {{
|
||||
await controller.submit('operator-token', 'Timmy’s Pixel');
|
||||
process.stdout.write(JSON.stringify(request));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"url": "api/v1/session",
|
||||
"body": {"access_token": "operator-token", "device_label": "Timmy’s Pixel"},
|
||||
}
|
||||
|
||||
|
||||
def test_shared_capture_login_explains_why_sign_in_is_required():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }}, continuation: './?title=Shared',
|
||||
}});
|
||||
process.stdout.write(status.textContent);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert result.stdout == "Sign in to continue your shared capture."
|
||||
|
||||
|
||||
def test_login_rejects_untrusted_or_non_share_continuations():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
crossOrigin: await destination('https://evil.example/steal'),
|
||||
otherRoute: await destination('./settings?title=Shared'),
|
||||
extraField: await destination('./?title=Shared&next=https%3A%2F%2Fevil.example'),
|
||||
prototypeField: await destination('./?toString=Shared'),
|
||||
oversized: await destination('./?title=' + 'x'.repeat(201)),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"crossOrigin": "./",
|
||||
"otherRoute": "./",
|
||||
"extraField": "./",
|
||||
"prototypeField": "./",
|
||||
"oversized": "./",
|
||||
}
|
||||
|
||||
|
||||
def test_login_bootstrap_continues_shared_capture_after_submit():
|
||||
harness = f"""
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const state = {{ replaced: null, submit: null }};
|
||||
const elements = {{
|
||||
'sign-in': {{ reset: () => {{}}, addEventListener: (_name, handler) => state.submit = handler }},
|
||||
'status': {{ textContent: '' }},
|
||||
'submit-sign-in': {{ disabled: false }},
|
||||
}};
|
||||
const context = {{
|
||||
URLSearchParams, Response, setInterval, clearInterval,
|
||||
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
|
||||
fetch: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
document: {{ getElementById: id => elements[id] }},
|
||||
window: {{ location: {{
|
||||
search: '?continue=.%2F%3Ftitle%3DShared%2Blink%26url%3Dhttps%253A%252F%252Fexample.com',
|
||||
replace: value => state.replaced = value,
|
||||
}} }},
|
||||
}};
|
||||
context.fetch.bind = Function.prototype.bind;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(LOGIN_JS))}, 'utf8'), context);
|
||||
state.submit({{ preventDefault: () => {{}} }});
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({{
|
||||
replaced: state.replaced, status: elements.status.textContent,
|
||||
}})), 0);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"replaced": "./?title=Shared+link&url=https%3A%2F%2Fexample.com",
|
||||
"status": "Signing in…",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
assert '<script src="static/login.js"></script>' in html
|
||||
assert "main{box-sizing:border-box" in html
|
||||
assert '<p id="status" role="status" aria-live="polite"></p>' in html
|
||||
assert 'name="device_label"' in html
|
||||
assert 'maxlength="64"' in html
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
@@ -9,6 +10,19 @@ FRONTEND = Path(__file__).parent.parent / "frontend"
|
||||
RENDERER = FRONTEND / "markdown.js"
|
||||
|
||||
|
||||
def render_markdown(payload):
|
||||
script = (
|
||||
f"const render = require({json.dumps(str(RENDERER))});"
|
||||
f"process.stdout.write(render({json.dumps(payload)}));"
|
||||
)
|
||||
return subprocess.run(
|
||||
["node", "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
|
||||
|
||||
class ScriptSourceParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -50,3 +64,77 @@ def test_markdown_script_resolves_inside_dashboard_subpath():
|
||||
assert urljoin(
|
||||
"https://forge.alexanderwhitestone.com/dashboard/", markdown_source
|
||||
) == "https://forge.alexanderwhitestone.com/dashboard/static/markdown.js"
|
||||
|
||||
|
||||
def test_markdown_renderer_preserves_mobile_reading_structure():
|
||||
rendered = render_markdown(
|
||||
"## Plan\n\n> Ship this\n\n- [x] Tested\n- [ ] Released\n\n"
|
||||
"```python\nprint('ready')\n```"
|
||||
)
|
||||
|
||||
assert rendered == (
|
||||
"<h2>Plan</h2><blockquote><p>Ship this</p></blockquote>"
|
||||
'<ul class="task-list"><li class="task-list-item">'
|
||||
'<input type="checkbox" disabled checked> Tested</li>'
|
||||
'<li class="task-list-item"><input type="checkbox" disabled> Released</li></ul>'
|
||||
'<pre><code class="language-python">print('ready')</code></pre>'
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_renderer_allows_only_safe_links_and_keeps_html_inert():
|
||||
rendered = render_markdown(
|
||||
"[Forge](https://forge.example/work?q=1&safe=yes) "
|
||||
"[relative](/git/org/repo/issues/1) "
|
||||
"[attack](javascript:alert(1)) <img src=x onerror=alert(2)>"
|
||||
)
|
||||
|
||||
assert (
|
||||
'<a href="https://forge.example/work?q=1&safe=yes" target="_blank" '
|
||||
'rel="noopener noreferrer">Forge</a>' in rendered
|
||||
)
|
||||
assert (
|
||||
'<a href="/git/org/repo/issues/1" target="_blank" '
|
||||
'rel="noopener noreferrer">relative</a>' in rendered
|
||||
)
|
||||
assert "javascript:" not in rendered
|
||||
assert "<img" not in rendered
|
||||
assert "<img src=x onerror=alert(2)>" in rendered
|
||||
|
||||
|
||||
def test_all_read_only_work_bodies_use_the_shared_markdown_renderer():
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
|
||||
expected_paths = (
|
||||
"renderMarkdown(comment.body || 'No comment body provided.')",
|
||||
"renderMarkdown(detail.subject_body || 'No subject context was provided.')",
|
||||
"renderMarkdown(detail.body || 'No description provided.')",
|
||||
"renderMarkdown(item.body || 'No description provided.')",
|
||||
"renderMarkdown(review.body)",
|
||||
"renderMarkdown(confirmed.body || 'No description provided.')",
|
||||
)
|
||||
for path in expected_paths:
|
||||
assert path in dashboard
|
||||
|
||||
assert dashboard.count("renderMarkdown(detail.body || 'No description provided.')") == 4
|
||||
|
||||
|
||||
def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
worker = (FRONTEND / "service-worker.js").read_text()
|
||||
|
||||
for body_id in (
|
||||
"issue-sheet-body",
|
||||
"pull-sheet-body",
|
||||
"review-sheet-body",
|
||||
"update-subject-body",
|
||||
"search-preview-body",
|
||||
):
|
||||
body_tag = re.search(rf'<div\b[^>]*\bid="{body_id}"[^>]*>', html)
|
||||
assert body_tag
|
||||
assert "markdown-content" in body_tag.group(0)
|
||||
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
@@ -0,0 +1,38 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mobile-composer-viewport.js"></script>' in html
|
||||
assert "createMobileComposerViewport({" in dashboard
|
||||
for panel, workspace, composer, submit, status in [
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
|
||||
|
||||
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
html = HTML.read_text()
|
||||
worker = WORKER.read_text()
|
||||
local_assets = set(re.findall(r'(?:src|href)="((?:static/|manifest\.webmanifest)[^"?#]*)', html))
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
|
||||
|
||||
|
||||
def run_controller_scenario(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
class Target {{
|
||||
constructor() {{ this.listeners = {{}}; }}
|
||||
addEventListener(name, handler) {{ (this.listeners[name] ||= new Set()).add(handler); }}
|
||||
removeEventListener(name, handler) {{ this.listeners[name]?.delete(handler); }}
|
||||
dispatch(name, event = {{}}) {{ for (const handler of this.listeners[name] || []) handler(event); }}
|
||||
count(name) {{ return this.listeners[name]?.size || 0; }}
|
||||
}}
|
||||
function element() {{
|
||||
const target = new Target();
|
||||
target.style = {{
|
||||
values: {{}},
|
||||
setProperty(name, value) {{ this.values[name] = value; }},
|
||||
removeProperty(name) {{ delete this.values[name]; }},
|
||||
}};
|
||||
target.scrollTop = 0;
|
||||
target.reveals = [];
|
||||
target.scrollIntoView = options => target.reveals.push(options);
|
||||
target.contains = candidate => candidate === target || candidate?.workspace === target;
|
||||
return target;
|
||||
}}
|
||||
const viewport = new Target();
|
||||
viewport.offsetTop = 12;
|
||||
viewport.height = 360;
|
||||
const mediaQuery = {{matches:true}};
|
||||
const panel = element();
|
||||
const workspace = element();
|
||||
const composer = element(); composer.workspace = workspace;
|
||||
const submit = element(); submit.workspace = workspace;
|
||||
const status = element(); status.workspace = workspace;
|
||||
const document = {{activeElement:null}};
|
||||
const scheduled = [];
|
||||
const controller = createController({{
|
||||
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
|
||||
schedule: callback => scheduled.push(callback),
|
||||
}});
|
||||
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
|
||||
{scenario}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_composer_uses_visual_viewport_and_reveals_delivery_controls():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
viewport.offsetTop = 20;
|
||||
viewport.height = 300;
|
||||
viewport.dispatch('resize');
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
geometry: panel.style.values,
|
||||
resizeListeners: viewport.count('resize'),
|
||||
scrollListeners: viewport.count('scroll'),
|
||||
revealCount: workspace.reveals.length,
|
||||
revealOptions: workspace.reveals.at(-1),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"geometry": {
|
||||
"--composer-viewport-top": "20px",
|
||||
"--composer-viewport-height": "300px",
|
||||
},
|
||||
"resizeListeners": 1,
|
||||
"scrollListeners": 1,
|
||||
"revealCount": 2,
|
||||
"revealOptions": {"block": "end", "inline": "nearest"},
|
||||
}
|
||||
|
||||
|
||||
def test_blur_restores_scroll_and_close_clears_geometry_without_duplicate_listeners():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
controller.start();
|
||||
panel.scrollTop = 240;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
composer.dispatch('focus');
|
||||
document.activeElement = null;
|
||||
workspace.dispatch('focusout');
|
||||
flush();
|
||||
const afterBlur = {scrollTop:panel.scrollTop, geometry:{...panel.style.values}, resize:viewport.count('resize')};
|
||||
panel.scrollTop = 99;
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
controller.close(panel);
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
afterBlur,
|
||||
afterClose:{scrollTop:panel.scrollTop, geometry:panel.style.values, resize:viewport.count('resize'), scroll:viewport.count('scroll')},
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"afterBlur": {"scrollTop": 240, "geometry": {}, "resize": 0},
|
||||
"afterClose": {"scrollTop": 99, "geometry": {}, "resize": 0, "scroll": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_desktop_and_missing_visual_viewport_are_noop():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
mediaQuery.matches = false;
|
||||
controller.start();
|
||||
document.activeElement = composer;
|
||||
composer.dispatch('focus');
|
||||
process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewport.count('resize'), reveals:workspace.reveals.length}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
|
||||
@@ -0,0 +1,72 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
||||
|
||||
|
||||
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
|
||||
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
|
||||
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
|
||||
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"today": "today",
|
||||
"attention": "attention",
|
||||
"all": "all",
|
||||
"saved": "review",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_disclosure_closes_on_escape_and_restores_launcher_focus():
|
||||
script = f"""
|
||||
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
|
||||
const listeners = {{}};
|
||||
const disclosure = {{open:true, addEventListener(name, fn) {{ listeners[name] = fn; }}}};
|
||||
const launcher = {{focuses:0, focus() {{ this.focuses += 1; }}}};
|
||||
const controller = mobileLaunch.createDisclosure({{disclosure, launcher}});
|
||||
controller.start();
|
||||
let prevented = false;
|
||||
listeners.keydown({{key:'Escape', preventDefault() {{ prevented = true; }}}});
|
||||
process.stdout.write(JSON.stringify({{open:disclosure.open, focuses:launcher.focuses, prevented}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"open": False, "focuses": 1, "prevented": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="app-brand"' in html
|
||||
assert 'class="app-live-status"' in html
|
||||
assert 'class="app-menu"' in html
|
||||
assert 'id="app-menu-toggle"' in html
|
||||
assert 'class="work-settings"' in html
|
||||
assert 'id="work-settings-toggle"' in html
|
||||
assert 'id="active-work-queue"' in html
|
||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "BASE + 'static/mobile-launch.js'" in service_worker
|
||||
assert "mobileLaunch.chooseFilter" in html
|
||||
assert "mobileLaunch.createDisclosure" in html
|
||||
assert "@media (max-width: 600px)" in html
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
|
||||
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
|
||||
assert ".my-work-actions button { min-height:44px;" in html
|
||||
@@ -4,10 +4,43 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
|
||||
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const state = {{active:true, resumable:true, today:2}};
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
openFallback: () => calls.push('fallback'),
|
||||
}});
|
||||
const modes = [];
|
||||
modes.push(entry.open());
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.resumable = false; modes.push(entry.open());
|
||||
state.today = 0; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "resume", "start", "work"],
|
||||
"calls": ["continue", "resume", "start", "fallback"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
@@ -70,6 +103,43 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const workLabel = {{ textContent:'' }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
|
||||
dock.updateWork('continue', 3);
|
||||
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('resume', 0);
|
||||
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
continuing, resuming, starting,
|
||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"continuing": {
|
||||
"text": "Continue",
|
||||
"count": "3",
|
||||
"hidden": False,
|
||||
"label": "Continue Today, 3 items need attention",
|
||||
},
|
||||
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -95,6 +165,24 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-work-label">Work</span>' in html
|
||||
assert '<script src="static/mobile-work-entry.js"></script>' in html
|
||||
assert "const mobileWorkEntry = createMobileWorkEntry({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "isTodayResumable: () => workSession.resumable()" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "continueToday: continueTodaySession" in html
|
||||
assert "resumeToday: resumeTodaySession" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "workSession.reopen()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
@@ -112,12 +200,12 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: openMobileWork" in html
|
||||
assert "counts.attention ? 'attention' : 'all'" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "const filter = counts.attention ? 'attention' : 'all';" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "mobileTaskDock.updateAttention(counts.attention)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
+834
-9
@@ -5,12 +5,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||
@@ -18,6 +19,175 @@ PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
|
||||
|
||||
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
||||
script = f"""
|
||||
const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
|
||||
function disclosure(name) {{
|
||||
const listeners = {{}};
|
||||
const attrs = {{}};
|
||||
const summary = {{
|
||||
focusCount: 0,
|
||||
setAttribute(key, value) {{ attrs[key] = value; }},
|
||||
focus() {{ this.focusCount += 1; }},
|
||||
}};
|
||||
return {{
|
||||
name, open: false, dataset: {{}}, summary, attrs,
|
||||
querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
|
||||
addEventListener(type, listener) {{ listeners[type] = listener; }},
|
||||
fire(type, event = {{}}) {{ listeners[type](event); }},
|
||||
}};
|
||||
}}
|
||||
const first = disclosure('first');
|
||||
const second = disclosure('second');
|
||||
const controller = createCardPlanning({{
|
||||
querySelectorAll() {{ return [first, second]; }},
|
||||
}});
|
||||
controller.wire();
|
||||
first.open = true;
|
||||
first.fire('toggle');
|
||||
second.open = true;
|
||||
second.fire('toggle');
|
||||
const escape = {{
|
||||
key: 'Escape', prevented: 0, stopped: 0,
|
||||
preventDefault() {{ this.prevented += 1; }},
|
||||
stopPropagation() {{ this.stopped += 1; }},
|
||||
}};
|
||||
second.fire('keydown', escape);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
firstOpen: first.open,
|
||||
firstExpanded: first.attrs['aria-expanded'],
|
||||
secondOpen: second.open,
|
||||
secondExpanded: second.attrs['aria-expanded'],
|
||||
secondFocus: second.summary.focusCount,
|
||||
prevented: escape.prevented,
|
||||
stopped: escape.stopped,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"firstOpen": False,
|
||||
"firstExpanded": "false",
|
||||
"secondOpen": False,
|
||||
"secondExpanded": "false",
|
||||
"secondFocus": 1,
|
||||
"prevented": 1,
|
||||
"stopped": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_my_work_cards_progressively_disclose_planning_on_phones():
|
||||
html = await dashboard()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/card-planning.js"></script>' in html
|
||||
assert "const cardPlanning = createCardPlanning(document);" in html
|
||||
assert "cardPlanning.wire();" in html
|
||||
assert (
|
||||
'<details class="card-planning" data-card-planning>'
|
||||
'<summary aria-expanded="false">Plan or defer</summary>'
|
||||
'<div class="card-planning-actions">'
|
||||
) in html
|
||||
assert '.card-planning > summary { display:none;' in html
|
||||
assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
|
||||
mobile = html.index('@media (max-width: 600px)')
|
||||
assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
|
||||
assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
|
||||
assert "BASE + 'static/card-planning.js'" in service_worker
|
||||
|
||||
|
||||
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
let finishClaim;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
||||
addToday: () => 'added',
|
||||
onClaimed: item => states.push({{status:'reconciled', item}}),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.open({{
|
||||
repository:'stackchain/api', title:'Retry deploy',
|
||||
issue:{{number:7, assignees:[], claimable:true}},
|
||||
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
||||
const first = controller.act();
|
||||
const second = controller.act();
|
||||
if (first !== second || claims !== 1) throw new Error('claim was not single-flight');
|
||||
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
||||
(async () => {{
|
||||
await first;
|
||||
const added = await controller.act();
|
||||
process.stdout.write(JSON.stringify({{claims, added, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["claims"] == 1
|
||||
assert output["added"] == "added"
|
||||
assert output["states"][0] == {"action": "claim", "busy": False, "message": ""}
|
||||
assert {"action": "claim", "busy": True, "message": "Assigning…"} in output["states"]
|
||||
reconciled = next(state for state in output["states"] if state.get("status") == "reconciled")
|
||||
assert reconciled["item"]["notification_id"] == 42
|
||||
assert reconciled["item"]["repository"] == "stackchain/api"
|
||||
assert reconciled["item"]["kind"] == "issue"
|
||||
assert output["states"][-1] == {
|
||||
"action": "today", "busy": False, "message": "Added to Today."
|
||||
}
|
||||
|
||||
|
||||
def test_update_ownership_conflict_removes_stale_action_without_reconciling():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
const states = [];
|
||||
let reconciled = 0;
|
||||
const conflict = new Error('server wording'); conflict.status = 409;
|
||||
const controller = createUpdateOwnership({{
|
||||
claim: () => Promise.reject(conflict), addToday: () => 'added',
|
||||
onClaimed: () => {{ reconciled += 1; }}, onState: state => states.push(state),
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.act();
|
||||
process.stdout.write(JSON.stringify({{result, reconciled, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "conflict"
|
||||
assert output["reconciled"] == 0
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Someone else claimed or closed this issue. The update is still unread.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-ownership.js"></script>' in html
|
||||
assert 'id="update-ownership-action"' in html
|
||||
assert 'hidden aria-describedby="update-sheet-status"' in html
|
||||
assert 'const updateOwnership = createUpdateOwnership({' in html
|
||||
assert 'updateOwnership.open(detail, selectedUpdate)' in html
|
||||
assert "qs('#update-ownership-action').addEventListener('click'" in html
|
||||
assert "lastContextSnapshot.issues" in html
|
||||
assert "todayWork.add(item)" in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||
@@ -664,7 +834,7 @@ async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
|
||||
assert 'id="resume-issue-draft"' in html
|
||||
assert 'id="shared-content-conflict"' in html
|
||||
assert '.shared-content-actions button { min-height:44px;' in html
|
||||
assert "history.replaceState({}, '', cleanUrl)" in html
|
||||
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
||||
assert "navigator.serviceWorker.register('service-worker.js')" in html
|
||||
|
||||
|
||||
@@ -741,7 +911,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands()
|
||||
|
||||
assert 'loadIssueLabelEditor(' not in open_handler
|
||||
assert 'loadIssueMilestoneEditor(' not in open_handler
|
||||
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
|
||||
assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
|
||||
assert "qs('#issue-planning').addEventListener('toggle'" in html
|
||||
assert 'planningLoader.open(selectedIssue)' in html
|
||||
assert 'id="retry-issue-planning"' in html
|
||||
@@ -825,7 +995,7 @@ process.stdout.write(JSON.stringify({{
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"deferred": True,
|
||||
"deferred": "deferred",
|
||||
"timmy": {
|
||||
"active": 0,
|
||||
"later": [{
|
||||
@@ -838,6 +1008,33 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
let login = '';
|
||||
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const unavailable = createLaterWork({{
|
||||
storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
|
||||
getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
|
||||
}});
|
||||
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
|
||||
login = 'timmy';
|
||||
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
|
||||
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
|
||||
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"noIdentity": "unavailable",
|
||||
"invalid": "invalid",
|
||||
"failedWrite": "unavailable",
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
@@ -964,7 +1161,7 @@ let sessionActive = false;
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
|
||||
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }},
|
||||
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
|
||||
}},
|
||||
session: {{ active:() => sessionActive }},
|
||||
close:() => calls.push(['close']),
|
||||
@@ -1000,6 +1197,32 @@ process.stdout.write(JSON.stringify({{outside,session,calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
|
||||
script = f"""
|
||||
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
|
||||
const calls = [];
|
||||
const controller = createDetailDefer({{
|
||||
laterWork: {{
|
||||
presetUntil:() => new Date('2026-08-08T16:00:00Z'),
|
||||
defer:() => 'unavailable',
|
||||
}},
|
||||
session: {{active:() => false}},
|
||||
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
|
||||
focus:() => calls.push('focus'), announce:message => calls.push(message),
|
||||
}});
|
||||
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
|
||||
process.stdout.write(JSON.stringify({{saved,calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"saved": False,
|
||||
"calls": ["Could not save Later on this device."],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
|
||||
html = await dashboard()
|
||||
@@ -1023,16 +1246,39 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||
assert 'data-work-filter="later"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert 'const laterWork = createLaterWork({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'getLogin: () => planningOwnerLogin' in html
|
||||
assert 'laterWork.partition(lastMyWork,' in html
|
||||
assert 'data-later-preset="today"' in html
|
||||
assert 'data-later-preset="tomorrow"' in html
|
||||
assert 'data-later-restore' in html
|
||||
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
|
||||
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html
|
||||
assert "const result = laterWork.defer(item, until)" in html
|
||||
assert 'laterWork.restore(item)' in html
|
||||
assert '.later-actions button { min-height:44px;' in html
|
||||
assert 'Deferred work stays unread and unchanged in Gitea.' in html
|
||||
assert "'Deferred until ' + fmt(until)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-picker.js"></script>' in html
|
||||
assert html.count('<button type="button" data-detail-defer-custom') == 4
|
||||
assert 'data-later-custom' in html
|
||||
assert 'id="later-picker"' in html
|
||||
assert 'role="dialog"' in html
|
||||
assert 'type="datetime-local"' in html
|
||||
assert 'id="later-picker-timezone"' in html
|
||||
assert 'id="later-picker-error"' in html
|
||||
assert 'createLaterPicker({' in html
|
||||
assert "laterPicker.open(item, button, 'detail')" in html
|
||||
assert "laterPicker.open(item, button, 'card')" in html
|
||||
assert "detailDefer.deferUntil(item, until, {" in html
|
||||
assert "laterWork.defer(item, until)" in html
|
||||
assert "fetch(" not in LATER_PICKER.read_text()
|
||||
assert '.later-picker-panel' in html
|
||||
assert '.later-picker { box-sizing:border-box; width:100%; height:100%;' in html
|
||||
assert 'env(safe-area-inset-bottom)' in html
|
||||
|
||||
|
||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||
@@ -1182,6 +1428,200 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let saves = 0;
|
||||
const checkpoint = {{
|
||||
read: () => null,
|
||||
save: () => {{ saves += 1; return true; }},
|
||||
clear: () => true,
|
||||
}};
|
||||
const items = [1, 2].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const progress = [];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all', checkpoint,
|
||||
onOpen: item => opened.push(item.number),
|
||||
onProgress: state => progress.push(state.index), onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
const savesBeforeReopen = saves;
|
||||
const reopened = session.reopen();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
|
||||
todayActive:session.checkpointed(),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"reopened": True,
|
||||
"opened": [1, 2, 2],
|
||||
"progress": [1, 2, 2],
|
||||
"savesBeforeReopen": 2,
|
||||
"savesAfterReopen": 2,
|
||||
"todayActive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_resumes_current_item_after_reload():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const items = [1, 2, 3].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const first = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: firstCheckpoint,
|
||||
onOpen: item => opened.push('first:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
first.start();
|
||||
first.next();
|
||||
|
||||
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin: () => 'timmy',
|
||||
}});
|
||||
const resumed = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all',
|
||||
checkpoint: secondCheckpoint,
|
||||
onOpen: item => opened.push('resumed:' + item.number),
|
||||
onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
const available = resumed.resumable();
|
||||
const didResume = resumed.resume();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"available": True,
|
||||
"didResume": True,
|
||||
"opened": ["first:1", "first:2", "resumed:2"],
|
||||
"checkpoint": {
|
||||
"version": 1,
|
||||
"login": "timmy",
|
||||
"identity": "issue:stackchain/dashboard:2:",
|
||||
"index": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let login = 'timmy';
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
|
||||
getFilter: () => 'all', checkpoint,
|
||||
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
login = 'alexander';
|
||||
const visibleToOtherAccount = session.resumable();
|
||||
const retainedForOwner = values.size;
|
||||
login = 'timmy';
|
||||
session.end();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"visibleToOtherAccount": False,
|
||||
"retainedForOwner": 1,
|
||||
"active": False,
|
||||
"stored": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
|
||||
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
|
||||
}})]]);
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [], getFilter:() => 'all', checkpoint,
|
||||
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
const resumed = session.resume();
|
||||
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
|
||||
|
||||
|
||||
def test_today_session_keeps_working_when_checkpoint_storage_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let errors = 0;
|
||||
const opened = [];
|
||||
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage:{{
|
||||
getItem:() => null,
|
||||
setItem:() => {{ throw new Error('quota'); }},
|
||||
removeItem:() => {{ throw new Error('quota'); }},
|
||||
}},
|
||||
getLogin:() => 'timmy',
|
||||
onError:() => {{ errors += 1; }},
|
||||
}});
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
|
||||
getFilter:() => 'all', checkpoint,
|
||||
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
|
||||
html = await dashboard()
|
||||
@@ -1196,6 +1636,23 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
|
||||
assert 'aria-live="polite" data-work-session-progress' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="resume-today-session"' in html
|
||||
assert 'id="end-today-session"' in html
|
||||
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'checkpoint: sessionCheckpoint' in html
|
||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume()" in html
|
||||
assert "workSession.end()" in html
|
||||
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
|
||||
html = await dashboard()
|
||||
@@ -1211,6 +1668,97 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
|
||||
assert opener in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html
|
||||
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
||||
"qs('#close-pull-sheet').addEventListener", 1
|
||||
)[0]
|
||||
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
|
||||
assert "if (workSession.active()) workSession.complete();" in close_handler
|
||||
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
|
||||
assert "const continuingSession = workSession.active();" in merge_handler
|
||||
assert "if (workSession.active()) workSession.complete();" in merge_handler
|
||||
assert "merged. Next work item opened." in merge_handler
|
||||
assert merge_handler.index("await pullController.merge") < merge_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
||||
html = await dashboard()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
|
||||
assert "else if (!continuingSession)" in merge_handler
|
||||
assert "merging.key + ' merged.'" in merge_handler
|
||||
assert "else if (continuingSession" not in merge_handler
|
||||
assert "onFinish: () =>" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
|
||||
html = await dashboard()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
|
||||
|
||||
assert "lastMyWork = lastMyWork.filter" in success_path
|
||||
assert "workSession.complete()" in success_path
|
||||
assert "lastMyWork = lastMyWork.filter" not in failure_path
|
||||
assert "workSession.complete()" not in failure_path
|
||||
assert "The pull request remains in My Work; refresh and retry." in failure_path
|
||||
assert "button.disabled = false;" in failure_path
|
||||
|
||||
|
||||
def test_work_session_can_start_at_a_newly_created_item():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'stackchain/dashboard', number}}));
|
||||
const opened = [];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items,
|
||||
getFilter: () => 'all',
|
||||
getMilestone: () => '',
|
||||
onOpen: item => opened.push(item.number),
|
||||
onProgress: () => {{}},
|
||||
onFinish: () => {{}},
|
||||
}});
|
||||
const started = session.start(items[2]);
|
||||
process.stdout.write(JSON.stringify({{started, opened}}));
|
||||
"""
|
||||
|
||||
output = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
assert json.loads(output) == {"started": True, "opened": [3]}
|
||||
|
||||
|
||||
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
|
||||
items = [
|
||||
{"kind": "issue", "is_review": False},
|
||||
@@ -1483,6 +2031,19 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="load-more-issue-repositories"' in html
|
||||
assert '>Load more repositories<' in html
|
||||
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
|
||||
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
|
||||
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
|
||||
assert 'appendIssueRepositories(payload.items)' in html
|
||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -1526,7 +2087,7 @@ async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requ
|
||||
assert 'aria-controls="' in html
|
||||
assert "const detailId = 'find-work-detail-' + index" in html
|
||||
assert 'class="find-work-detail"' in html
|
||||
assert "escapeHtml(item.body || 'No description provided.')" in html
|
||||
assert "renderMarkdown(item.body || 'No description provided.')" in html
|
||||
assert '>Open in Gitea</a>' in html
|
||||
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
|
||||
assert '.find-work-detail { min-width:0;' in html
|
||||
@@ -1585,6 +2146,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"][1]["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = 0;
|
||||
let release;
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
fetchJson: url => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('temporary'));
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
|
||||
}}); }});
|
||||
}},
|
||||
}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||
capture.loadRepositoryPage(2).catch(() => {{
|
||||
const first = capture.loadRepositoryPage(2);
|
||||
const duplicate = capture.loadRepositoryPage(2);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, results, draft:capture.loadDraft()
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 2
|
||||
assert output["results"] == [
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
]
|
||||
assert output["draft"] == {
|
||||
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
@@ -1801,6 +2408,113 @@ Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.lo
|
||||
assert output["stored"]["number"] == 221
|
||||
|
||||
|
||||
def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: url => {{
|
||||
calls.push(url);
|
||||
return Promise.resolve({{items:[
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
|
||||
{{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
|
||||
{{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
|
||||
]}});
|
||||
}},
|
||||
}});
|
||||
capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state =>
|
||||
process.stdout.write(JSON.stringify({{calls, state}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == [
|
||||
"api/v1/search?q=Investigate%20checkout%20latency&limit=10"
|
||||
]
|
||||
assert output["state"]["status"] == "ready"
|
||||
assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]
|
||||
|
||||
|
||||
def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const pending = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
|
||||
}});
|
||||
const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
|
||||
const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
|
||||
const first = capture.findDuplicates(firstDraft);
|
||||
const latest = capture.findDuplicates(latestDraft);
|
||||
pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
|
||||
latest.then(latestState => {{
|
||||
const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
|
||||
capture.acknowledgeDuplicates(latestDraft);
|
||||
const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
|
||||
const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
|
||||
const changedSearch = capture.findDuplicates(changedDraft);
|
||||
pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
|
||||
changedSearch.then(() => {{
|
||||
const changed = capture.needsDuplicateAcknowledgement(changedDraft);
|
||||
pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
|
||||
first.then(staleState => {{
|
||||
const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
|
||||
pending[3].reject(new Error('offline'));
|
||||
failure.then(failedState => process.stdout.write(JSON.stringify({{
|
||||
latestState, staleState, blocked, allowed, changed, failedState,
|
||||
failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
|
||||
}})));
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["latestState"]["status"] == "ready"
|
||||
assert output["staleState"]["status"] == "stale"
|
||||
assert output["blocked"] is True
|
||||
assert output["allowed"] is False
|
||||
assert output["changed"] is True
|
||||
assert output["failedState"]["status"] == "failed"
|
||||
assert output["failureBlocks"] is False
|
||||
|
||||
|
||||
def test_issue_capture_keeps_partial_duplicate_search_advisory():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: () => Promise.resolve({{partial:true, items:[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
|
||||
]}}),
|
||||
}});
|
||||
capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
|
||||
state, blocked:capture.needsDuplicateAcknowledgement(draft),
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["state"]["partial"] is True
|
||||
assert [item["number"] for item in output["state"]["candidates"]] == [24]
|
||||
assert output["blocked"] is False
|
||||
|
||||
|
||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
@@ -2218,6 +2932,98 @@ reader.open(firstItem).then(async () => {{
|
||||
assert output["states"] == [[41], [90]]
|
||||
|
||||
|
||||
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const createPager = require({json.dumps(str(CONVERSATION))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const saved = {{
|
||||
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
|
||||
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
|
||||
}};
|
||||
let detailLoads = 0;
|
||||
let conversationLoads = 0;
|
||||
let markReads = 0;
|
||||
const details = [];
|
||||
const conversations = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
|
||||
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
|
||||
markRead: async () => {{ markReads += 1; }}, createPager,
|
||||
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
|
||||
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
const opened = await reader.open(item, saved);
|
||||
const older = await reader.loadOlder();
|
||||
const marked = await reader.markReadAndNext([item]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"older": False,
|
||||
"marked": False,
|
||||
"detailLoads": 0,
|
||||
"conversationLoads": 0,
|
||||
"markReads": 0,
|
||||
"details": ["Saved update"],
|
||||
"conversations": [[41]],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'update', notification_id:42, has_update:true, title:'First'}},
|
||||
{{kind:'update', notification_id:43, has_update:true, title:'Second'}},
|
||||
];
|
||||
const saved = new Map([
|
||||
[42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
|
||||
[43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
|
||||
]);
|
||||
const events = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async () => {{ throw new Error('network must not run'); }},
|
||||
markRead: async () => {{ throw new Error('network must not run'); }},
|
||||
queueRead: async id => events.push(['queued', id]),
|
||||
loadSaved: item => saved.get(item.notification_id),
|
||||
onOpen: item => events.push(['open', item.notification_id]),
|
||||
onDetail: detail => events.push(['detail', detail.id]),
|
||||
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
|
||||
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
||||
}});
|
||||
(async () => {{
|
||||
await reader.open(items[0], saved.get(42));
|
||||
events.length = 0;
|
||||
const result = await reader.markReadAndNext(items);
|
||||
process.stdout.write(JSON.stringify({{result, events}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
assert output["events"] == [
|
||||
["status", "Queueing update read…"],
|
||||
["queued", 42],
|
||||
["items", [43]],
|
||||
["open", 43],
|
||||
["status", "Loading update…"],
|
||||
["detail", 43],
|
||||
["status", "Update ready."],
|
||||
]
|
||||
|
||||
|
||||
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
@@ -2891,6 +3697,25 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-duplicates"' in html
|
||||
assert 'id="create-issue-duplicate-list"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
|
||||
assert "issueCapture.findDuplicates(draft)" in html
|
||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
||||
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
||||
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
||||
"issueOutbox.enqueueDurably(durableDraft)"
|
||||
)
|
||||
assert ".create-issue-duplicate-card" in html
|
||||
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -36,7 +36,12 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
|
||||
comments = [comment(i) for i in (range(1, 21) if page == 1 else range(41, 48))]
|
||||
return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})
|
||||
if request.url.path.endswith("/repos/stackchain/api/issues/7"):
|
||||
return httpx.Response(200, json={"body": "Deploy fails after retries."})
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"state": "open",
|
||||
"assignees": [],
|
||||
"body": "Deploy fails after retries.",
|
||||
})
|
||||
if request.url.path.endswith("/repos/stackchain/api/issues/comments/47"):
|
||||
return httpx.Response(200, json=comment(47))
|
||||
raise AssertionError(f"unexpected request: {request.url}")
|
||||
@@ -56,6 +61,11 @@ async def test_notification_detail_opens_the_newest_conversation_page_in_chronol
|
||||
"older_page": 2,
|
||||
"total": 47,
|
||||
}
|
||||
assert result["issue"] == {
|
||||
"number": 7,
|
||||
"assignees": [],
|
||||
"claimable": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTBOX = ROOT / "frontend" / "notification-read-outbox.js"
|
||||
BACKGROUND_SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_offline_read_admission_is_account_bound_deduplicated_and_suppresses_saved_updates():
|
||||
script = f"""
|
||||
const createOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
}};
|
||||
const reconciled = [];
|
||||
const backgroundSync = {{
|
||||
reconcile: async (items, lane) => reconciled.push([lane, items]),
|
||||
requestSync: async () => true,
|
||||
}};
|
||||
const outbox = createOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:() => 'timmy', now:() => 1234,
|
||||
}});
|
||||
(async () => {{
|
||||
const first = await outbox.enqueueDurably(42);
|
||||
const duplicate = await outbox.enqueueDurably(42);
|
||||
const visible = outbox.suppress([
|
||||
{{notification_id:42, title:'Queued'}}, {{notification_id:43, title:'Visible'}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
first, duplicate, items:outbox.list(), visible, reconciled,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"]["item"]["notificationId"] == 42
|
||||
assert output["duplicate"]["item"]["id"] == output["first"]["item"]["id"]
|
||||
assert len(output["items"]) == 1
|
||||
assert output["items"][0]["ownerLogin"] == "timmy"
|
||||
assert [item["notification_id"] for item in output["visible"]] == [43]
|
||||
assert output["reconciled"][-1][0] == "notification-read"
|
||||
assert len(output["reconciled"][-1][1]) == 1
|
||||
|
||||
|
||||
def test_foreground_flush_patches_each_unique_read_and_keeps_transient_failures_queued():
|
||||
script = f"""
|
||||
const createOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key => values.get(key) || null,setItem:(key,value) => values.set(key,value)}};
|
||||
const calls = [];
|
||||
const outbox = createOutbox({{
|
||||
storage, getOwnerLogin:() => 'timmy',
|
||||
fetchJson: async (url, options) => {{
|
||||
calls.push([url, options.method]);
|
||||
if (url.includes('/43/')) {{ const error = new Error('offline'); error.status = 503; throw error; }}
|
||||
return {{ok:true}};
|
||||
}},
|
||||
}});
|
||||
(async () => {{
|
||||
await outbox.enqueueDurably(42);
|
||||
await outbox.enqueueDurably(43);
|
||||
const result = await outbox.flush('timmy');
|
||||
process.stdout.write(JSON.stringify({{calls, result, items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
["api/v1/notifications/42/read", "PATCH"],
|
||||
["api/v1/notifications/43/read", "PATCH"],
|
||||
]
|
||||
assert output["result"]["confirmed"] == [42]
|
||||
assert [item["notificationId"] for item in output["items"]] == [43]
|
||||
assert output["items"][0]["status"] == "queued"
|
||||
|
||||
|
||||
def test_background_delivery_uses_patch_and_reports_read_receipt():
|
||||
script = f"""
|
||||
const createSync = require({json.dumps(str(BACKGROUND_SYNC))});
|
||||
const requests = [];
|
||||
const store = {{
|
||||
upsert:async () => {{}}, claim:async () => ({{
|
||||
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
|
||||
ownerLogin:'timmy', status:'sending',
|
||||
}}), complete:async () => {{}}, release:async () => {{}}, fail:async () => {{}},
|
||||
}};
|
||||
const sync = createSync({{
|
||||
store,
|
||||
fetchJson:async (url, options) => {{ requests.push([url, options.method, options.body || null]); return {{ok:true}}; }},
|
||||
}});
|
||||
(async () => {{
|
||||
const result = await sync.send({{
|
||||
id:'notification-read:timmy:42', kind:'notification-read', notificationId:42,
|
||||
ownerLogin:'timmy', status:'queued',
|
||||
}}, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{requests, result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["requests"] == [["api/v1/notifications/42/read", "PATCH", None]]
|
||||
assert output["result"]["receipt"] == {
|
||||
"id": "notification-read:timmy:42",
|
||||
"status": "confirmed",
|
||||
"kind": "notification-read",
|
||||
"route": "#/my-work/updates",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_offline_queue_read_next_through_precached_background_outbox():
|
||||
html = await dashboard()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert '<script src="static/notification-read-outbox.js"></script>' in html
|
||||
assert "const notificationReadOutbox = createNotificationReadOutbox({" in html
|
||||
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
|
||||
assert "notificationReadOutbox.suppress(saved.notifications || [])" in html
|
||||
assert "notificationReadOutbox.flush(activeFlushLogin)" in html
|
||||
assert "Queue read & next" in html
|
||||
assert "BASE + 'static/notification-read-outbox.js'" in worker
|
||||
@@ -0,0 +1,150 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OFFLINE_TODAY = Path(__file__).parents[1] / "frontend" / "offline-today.js"
|
||||
|
||||
|
||||
def run_scenario(scenario: str) -> dict:
|
||||
script = f"""
|
||||
const createOfflineToday = require({json.dumps(str(OFFLINE_TODAY))});
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_warms_bounded_today_queue_with_two_concurrent_requests():
|
||||
result = run_scenario("""
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const loaded = [];
|
||||
const saved = [];
|
||||
const items = Array.from({length:7}, (_, index) => ({
|
||||
kind:index % 2 ? 'pull' : 'issue', repository:'stackchain/dashboard', number:index + 1,
|
||||
updated_at:'2026-08-08T12:0' + index + ':00Z',
|
||||
}));
|
||||
const warmer = createOfflineToday({
|
||||
loadDetail: async item => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
loaded.push(item.number);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
return {title:'Work ' + item.number, body:'Context ' + item.number};
|
||||
},
|
||||
loadSavedDetail: () => null,
|
||||
saveDetail: (login, item, detail) => saved.push({login, number:item.number, detail}),
|
||||
});
|
||||
const status = await warmer.warm('timmy', items);
|
||||
process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
|
||||
""")
|
||||
|
||||
assert result["peak"] == 2
|
||||
assert result["loaded"] == [1, 2, 3, 4, 5]
|
||||
assert [entry["number"] for entry in result["saved"]] == [1, 2, 3, 4, 5]
|
||||
assert result["saved"][0]["detail"]["source_updated_at"] == "2026-08-08T12:00:00Z"
|
||||
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
|
||||
|
||||
|
||||
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
|
||||
result = run_scenario("""
|
||||
const items = [
|
||||
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'new'},
|
||||
{kind:'pull', repository:'stackchain/dashboard', number:2, updated_at:'same'},
|
||||
];
|
||||
const old = new Map([
|
||||
['issue:stackchain/dashboard:1', {title:'Prior 1', source_updated_at:'old'}],
|
||||
['pull:stackchain/dashboard:2', {title:'Prior 2', source_updated_at:'same'}],
|
||||
]);
|
||||
const loaded = [];
|
||||
const warmer = createOfflineToday({
|
||||
loadDetail: async item => { loaded.push(item.number); throw new Error('temporary outage'); },
|
||||
loadSavedDetail: (_login, item) => old.get(item.kind + ':' + item.repository + ':' + item.number),
|
||||
saveDetail: () => { throw new Error('must not replace a prior copy after failure'); },
|
||||
});
|
||||
const status = await warmer.warm('timmy', items);
|
||||
process.stdout.write(JSON.stringify({loaded, status, failed:warmer.failedKeys()}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"loaded": [1],
|
||||
"status": {"total": 2, "ready": 2, "failed": 1, "pending": 0},
|
||||
"failed": ["issue:stackchain/dashboard:1"],
|
||||
}
|
||||
|
||||
|
||||
def test_retry_fetches_only_failed_items():
|
||||
result = run_scenario("""
|
||||
const items = [
|
||||
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v2'},
|
||||
{kind:'issue', repository:'stackchain/dashboard', number:2, updated_at:'v1'},
|
||||
];
|
||||
let fail = true;
|
||||
const calls = [];
|
||||
const saved = new Map([['issue:stackchain/dashboard:2', {source_updated_at:'v1'}]]);
|
||||
const warmer = createOfflineToday({
|
||||
loadDetail: async item => {
|
||||
calls.push(item.number);
|
||||
if (fail) throw new Error('offline');
|
||||
return {title:'Fresh'};
|
||||
},
|
||||
loadSavedDetail: (_login, item) => saved.get(item.kind + ':' + item.repository + ':' + item.number),
|
||||
saveDetail: (_login, item, detail) => saved.set(item.kind + ':' + item.repository + ':' + item.number, detail),
|
||||
});
|
||||
await warmer.warm('timmy', items);
|
||||
fail = false;
|
||||
const status = await warmer.retry('timmy', items);
|
||||
process.stdout.write(JSON.stringify({calls, status}));
|
||||
""")
|
||||
|
||||
assert result["calls"] == [1, 1]
|
||||
assert result["status"] == {"total": 2, "ready": 2, "failed": 0, "pending": 0}
|
||||
|
||||
|
||||
def test_requested_reviews_are_warmed_as_distinct_today_items():
|
||||
result = run_scenario("""
|
||||
const loaded = [];
|
||||
const saved = [];
|
||||
const review = {
|
||||
kind:'pull', is_review:true, repository:'stackchain/dashboard', number:12,
|
||||
updated_at:'2026-08-08T12:00:00Z',
|
||||
};
|
||||
const warmer = createOfflineToday({
|
||||
loadDetail: async item => {
|
||||
loaded.push(item.is_review ? 'review' : item.kind);
|
||||
return {head_sha:'abc123', files:[{filename:'src/app.js'}]};
|
||||
},
|
||||
loadSavedDetail: () => null,
|
||||
saveDetail: (_login, item, detail) => saved.push({item, detail}),
|
||||
});
|
||||
const status = await warmer.warm('timmy', [review]);
|
||||
process.stdout.write(JSON.stringify({loaded, saved, status}));
|
||||
""")
|
||||
|
||||
assert result["loaded"] == ["review"]
|
||||
assert result["saved"][0]["item"]["is_review"] is True
|
||||
assert result["saved"][0]["detail"]["head_sha"] == "abc123"
|
||||
assert result["status"] == {"total": 1, "ready": 1, "failed": 0, "pending": 0}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_warms_today_after_fresh_snapshot_and_exposes_mobile_retry():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/offline-today.js"></script>' in html
|
||||
assert 'id="offline-today-status"' in html
|
||||
assert 'id="retry-offline-today"' in html
|
||||
assert "offlineToday.warm(confirmedOwnerLogin, todayMyWork)" in html
|
||||
assert "offlineToday.retry(confirmedOwnerLogin, todayMyWork)" in html
|
||||
assert "Today offline: " in html
|
||||
assert ".offline-today-readiness button { min-height:44px;" in html
|
||||
+162
-1
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
|
||||
@@ -87,6 +87,115 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load(
|
||||
assert result == {"enabled": False, "loaded": None}
|
||||
|
||||
|
||||
def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2});
|
||||
store.setEnabled(true);
|
||||
const issue = {kind:'issue', repository:'stackchain/dashboard', number:8};
|
||||
store.saveDetail('timmy', issue, {
|
||||
title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'],
|
||||
url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}],
|
||||
conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z',
|
||||
token:'comment-secret'}], page:1, older_page:null, total:1},
|
||||
});
|
||||
store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'});
|
||||
store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'});
|
||||
const raw = [...values.values()].join(' ');
|
||||
process.stdout.write(JSON.stringify({
|
||||
evicted:store.loadDetail('timmy', issue),
|
||||
loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}),
|
||||
wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}),
|
||||
raw,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["evicted"] is None
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["title"] == "PR 9"
|
||||
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
|
||||
assert "secret" not in result["raw"]
|
||||
assert "private diff" not in result["raw"]
|
||||
|
||||
|
||||
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const update = {kind:'update', notification_id:42};
|
||||
store.saveDetail('timmy', update, {
|
||||
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
|
||||
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
|
||||
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
|
||||
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
|
||||
conversation:{
|
||||
comments:Array.from({length:25}, (_, index) => ({
|
||||
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
|
||||
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
|
||||
})),
|
||||
page:1, older_page:2, total:40,
|
||||
},
|
||||
});
|
||||
const raw = [...values.values()].join(' ');
|
||||
process.stdout.write(JSON.stringify({
|
||||
loaded:store.loadDetail('timmy', update),
|
||||
wrongUser:store.loadDetail('alexander', update),
|
||||
raw,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["title"] == "Deployment blocked"
|
||||
assert result["loaded"]["subject_body"] == "Safe subject context"
|
||||
assert result["loaded"]["subject_type"] == "Issue"
|
||||
assert len(result["loaded"]["conversation"]["comments"]) == 20
|
||||
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
|
||||
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
|
||||
assert "secret" not in result["raw"]
|
||||
assert "private diff" not in result["raw"]
|
||||
|
||||
|
||||
def test_requested_review_cache_is_sha_scoped_allowlisted_and_bounded():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const review = {kind:'pull', repository:'stackchain/dashboard', number:12, is_review:true};
|
||||
store.saveDetail('timmy', review, {
|
||||
title:'Review offline work', body:'Review description', author:'alexander', head_sha:'abc123',
|
||||
ci_state:'success', token:'top-secret', source_updated_at:'2026-08-07T11:00:00Z',
|
||||
files:Array.from({length:55}, (_, fileIndex) => ({
|
||||
filename:'src/file-' + fileIndex + '.js', status:'modified', additions:2, deletions:1,
|
||||
diff_available:true, diff_binary:false, diff_truncated:false, patch:'must-not-persist',
|
||||
diff_lines:Array.from({length:405}, (_, lineIndex) => '+' + fileIndex + ':' + lineIndex),
|
||||
})),
|
||||
reviews:Array.from({length:25}, (_, index) => ({
|
||||
id:index + 1, state:'COMMENT', body:'Review ' + (index + 1),
|
||||
submitted_at:'2026-08-07T10:00:00Z', user:{login:'reviewer-' + index, email:'private@example.test'},
|
||||
token:'review-secret',
|
||||
})),
|
||||
});
|
||||
const loaded = store.loadDetail('timmy', review);
|
||||
process.stdout.write(JSON.stringify({
|
||||
loaded,
|
||||
wrongUser:store.loadDetail('alexander', review),
|
||||
raw:[...values.values()].join(' '),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["head_sha"] == "abc123"
|
||||
assert result["loaded"]["ci_state"] == "success"
|
||||
assert len(result["loaded"]["files"]) == 50
|
||||
assert len(result["loaded"]["files"][0]["diff_lines"]) == 400
|
||||
assert result["loaded"]["files"][0]["filename"] == "src/file-0.js"
|
||||
assert len(result["loaded"]["reviews"]) == 20
|
||||
assert result["loaded"]["reviews"][0]["body"] == "Review 6"
|
||||
assert result["loaded"]["reviews"][0]["user"] == {"login": "reviewer-5"}
|
||||
assert "top-secret" not in result["raw"]
|
||||
assert "must-not-persist" not in result["raw"]
|
||||
assert "private@example.test" not in result["raw"]
|
||||
assert "review-secret" not in result["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
|
||||
html = await dashboard()
|
||||
@@ -114,3 +223,55 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
|
||||
assert "Live details and actions will return automatically." in html
|
||||
assert "function renderLiveSnapshot(snapshot, changedSections" in html
|
||||
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_today_details_open_offline_without_enabling_server_state_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html
|
||||
assert "Details not saved—reconnect to open this item." in html
|
||||
assert "openIssueSheet(item, trigger, savedDetail)" in html
|
||||
assert "openPullSheet(item, trigger, savedDetail)" in html
|
||||
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html
|
||||
assert "todayWork.contains(item)" in html
|
||||
assert "Offline copy · saved " in html
|
||||
assert "setOfflineDetailControls('issue')" in html
|
||||
assert "setOfflineDetailControls('pull')" in html
|
||||
assert "qs('#issue-planning').inert = true;" in html
|
||||
assert "qs('#issue-handoff').inert = true;" in html
|
||||
assert "qs('#pull-review').inert = true;" in html
|
||||
assert "qs('#issue-planning').inert = false;" in html
|
||||
assert "qs('#pull-review').inert = false;" in html
|
||||
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert "notificationReader.open(item, savedDetail)" in html
|
||||
assert "openRoutedWork(item, button);" in html
|
||||
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
|
||||
assert "Offline update · saved " in html
|
||||
assert "setOfflineUpdateControls(true)" in html
|
||||
assert "qs('#mark-update-read-next').disabled = false;" in html
|
||||
assert "offline ? 'Queue read & next' : 'Mark read & next'" in html
|
||||
assert "qs('#update-ownership-action').disabled = offline;" in html
|
||||
assert "qs('#load-older-update-comments').disabled = offline;" in html
|
||||
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
|
||||
assert "replies and read acknowledgements queue for sync" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
|
||||
html = await dashboard()
|
||||
|
||||
assert "item.is_review ? reviewController.load(item)" in html
|
||||
assert "openReviewSheet(item, trigger, savedDetail)" in html
|
||||
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
|
||||
assert "cachedDetail || await reviewController.load(selectedReview)" in html
|
||||
assert "Offline review · saved " in html
|
||||
assert "Reconnect to validate & submit" in html
|
||||
assert "qs('#submit-review').disabled = offlineReview;" in html
|
||||
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
@@ -110,13 +110,15 @@ const seen=[];
|
||||
const unsubscribe=second.subscribe(change=>seen.push(change));
|
||||
first.notify('issue');
|
||||
first.notify('authored');
|
||||
first.notify('today');
|
||||
first.notify('later');
|
||||
unsubscribe();
|
||||
first.notify('issue');
|
||||
process.stdout.write(JSON.stringify({{seen,signal:JSON.parse(storage.getItem('stackchain.outbox-change.v1'))}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [change["queue"] for change in output["seen"]] == ["issue", "authored"]
|
||||
assert [change["queue"] for change in output["seen"]] == ["issue", "authored", "today", "later"]
|
||||
assert output["signal"]["queue"] == "issue"
|
||||
assert output["signal"]["tabId"] == "first"
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
DASHBOARD = Path("frontend/index.html")
|
||||
|
||||
|
||||
def test_dashboard_panels_are_independently_collapsible():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
|
||||
assert f'<details class="panel' in html
|
||||
@@ -13,7 +15,7 @@ def test_dashboard_panels_are_independently_collapsible():
|
||||
|
||||
|
||||
def test_panel_open_state_is_restored_and_saved_in_local_storage():
|
||||
html = DASHBOARD.read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
|
||||
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
|
||||
PLAN_TODAY_PREVIEW = Path(__file__).parents[1] / "frontend" / "plan-today-preview.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_plan_today_drafts_capacity_order_and_cancel_without_writing():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const saved = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
limit: 3,
|
||||
save: ids => saved.push(ids),
|
||||
}});
|
||||
planner.open([item(1), item(2)], [item(1), item(2), item(3), item(4)]);
|
||||
const add = planner.toggle(item(3));
|
||||
const full = planner.toggle(item(4));
|
||||
const moved = planner.move('issue:stackchain/dashboard:3:', 'up');
|
||||
const snapshot = planner.snapshot();
|
||||
planner.cancel();
|
||||
process.stdout.write(JSON.stringify({{add, full, moved, snapshot, afterCancel:planner.snapshot(), saved}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"add": "added",
|
||||
"full": "full",
|
||||
"moved": True,
|
||||
"snapshot": {
|
||||
"open": True,
|
||||
"ids": [
|
||||
"issue:stackchain/dashboard:1:",
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
"count": 3,
|
||||
"limit": 3,
|
||||
},
|
||||
"afterCancel": {"open": False, "ids": [], "count": 0, "limit": 3},
|
||||
"saved": [],
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_saves_exact_draft_and_starts_first_item_only_after_success():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const calls = [];
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: ids => {{ calls.push(['save', ...ids]); return true; }},
|
||||
start: first => calls.push(['start', first.number]),
|
||||
}});
|
||||
planner.open([item(1)], [item(1), item(2)]);
|
||||
planner.toggle(item(2));
|
||||
const result = planner.commit({{start:true}});
|
||||
process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": "saved",
|
||||
"calls": [
|
||||
["save", "issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
["start", 1],
|
||||
],
|
||||
"snapshot": {"open": False, "ids": [], "count": 0, "limit": 5},
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const events = [];
|
||||
let scroll = 318;
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: () => true,
|
||||
}});
|
||||
planner.open([item(1)], [item(1), item(2)]);
|
||||
const preview = createPlanTodayPreview({{
|
||||
planner,
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
getScroll: () => scroll,
|
||||
setScroll: value => {{ scroll = value; }},
|
||||
onOpen: value => events.push(['open', value.number]),
|
||||
onClose: (value, trigger) => events.push(['close', value.number, trigger]),
|
||||
}});
|
||||
preview.open(item(2), 'preview-2');
|
||||
scroll = 0;
|
||||
const first = preview.close({{add:true}});
|
||||
preview.open(item(2), 'preview-2');
|
||||
scroll = 0;
|
||||
const second = preview.close({{add:true}});
|
||||
process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": "added",
|
||||
"second": "already-added",
|
||||
"scroll": 318,
|
||||
"events": [
|
||||
["open", 2],
|
||||
["close", 2, "preview-2"],
|
||||
["open", 2],
|
||||
["close", 2, "preview-2"],
|
||||
],
|
||||
"planner": {
|
||||
"open": True,
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"count": 2,
|
||||
"limit": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/plan-today.js"></script>' in html
|
||||
assert 'id="plan-today"' in html
|
||||
assert 'id="plan-today-sheet" role="dialog"' in html
|
||||
assert 'id="plan-today-capacity"' in html
|
||||
assert 'id="save-and-start-today"' in html
|
||||
assert "const planToday = createPlanToday({" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
assert "todaySync.enqueue('add'" in html
|
||||
assert "qs('#plan-today').addEventListener('click'" in html
|
||||
assert ".plan-today-actions { position:sticky; bottom:0;" in html
|
||||
assert ".plan-today-actions button { min-height:44px;" in html
|
||||
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
|
||||
assert ".plan-today-item { grid-template-columns:1fr; }" in html
|
||||
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
||||
assert ".plan-preview-actions { position:fixed; z-index:76;" in html
|
||||
assert '<script src="static/plan-today-preview.js"></script>' in html
|
||||
assert 'data-plan-preview="' in html
|
||||
assert 'id="add-plan-preview"' in html
|
||||
assert "taskOverlayHistory.open('plan-today-preview')" in html
|
||||
assert "planTodayPreview.close({ add:true })" in html
|
||||
assert "Preview unavailable offline" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_plan_today_wires_cancel_back_and_success_through_overlay_history():
|
||||
html = await dashboard()
|
||||
|
||||
assert "taskOverlayHistory.open('plan-today')" in html
|
||||
assert "previous === 'plan-today' && kind !== 'plan-today'" in html
|
||||
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
|
||||
assert "openPlanToday(planTodayTrigger, false)" in html
|
||||
assert "closePlanToday(false)" in html
|
||||
assert "if (result === 'saved') taskOverlayHistory.leave();" in html
|
||||
|
||||
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-device-data.js"
|
||||
|
||||
|
||||
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
|
||||
harness = f"""
|
||||
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
|
||||
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
|
||||
const storage = {{
|
||||
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
|
||||
get length(){{return this.values.size;}},
|
||||
key(index){{return Array.from(this.values.keys())[index] || null;}},
|
||||
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
|
||||
}};
|
||||
const clear = createPrivateDeviceDataPurger({{
|
||||
localStorage:storage, sessionStorage:storage,
|
||||
indexedDB:{{deleteDatabase:name=>{{
|
||||
state.databases.push(name);
|
||||
const request={{}};
|
||||
setTimeout(()=>{{state.complete=true;request.onsuccess?.();}},10);
|
||||
return request;
|
||||
}}}},
|
||||
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
|
||||
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
|
||||
MessageChannel:class{{constructor(){{
|
||||
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
|
||||
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
|
||||
this.port1=first;this.port2=second;
|
||||
}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await clear();
|
||||
process.stdout.write(JSON.stringify({{...state,remaining:Array.from(storage.values.keys())}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
state = json.loads(result.stdout)
|
||||
|
||||
assert state["remaining"] == ["other.preference"]
|
||||
assert state["databases"] == ["stackchain-background-outbox-v1"]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert state["complete"] is True
|
||||
@@ -32,3 +32,37 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
|
||||
assert "`/readyz`" in text
|
||||
assert "does not contact Gitea" in text
|
||||
assert "HTTP 503" in text
|
||||
|
||||
|
||||
def test_readme_documents_exact_later_return_times():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "Choose date & time" in text
|
||||
assert "valid future local date and time" in text
|
||||
assert "exact instant" in text
|
||||
|
||||
|
||||
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "opened Today issue or pull request" in text
|
||||
assert "newest 20 comments" in text
|
||||
assert "comments can enter the account-bound durable outbox" in text
|
||||
assert "planning, assignment, review, merge, and close controls remain disabled" in text
|
||||
|
||||
|
||||
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "previously opened unread update" in text
|
||||
assert "replies enter the account-bound durable outbox" in text
|
||||
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
||||
|
||||
|
||||
def test_readme_documents_planning_sync_retention_contract():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "30 days" in text
|
||||
assert "4,096 operation receipts per account" in text
|
||||
assert "expired edit" in text
|
||||
assert "account plan is kept" in text
|
||||
@@ -0,0 +1,104 @@
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGER = ROOT / "scripts" / "build_release.py"
|
||||
VERIFIER = ROOT / "scripts" / "verify_release.py"
|
||||
|
||||
|
||||
def _fixture(root: Path) -> None:
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "frontend").mkdir()
|
||||
(root / "src" / "main.py").write_text("print('ready')\n")
|
||||
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
|
||||
(root / "requirements.txt").write_text("fastapi==1.0\n")
|
||||
(root / "README.md").write_text("# Stackchain\n")
|
||||
|
||||
|
||||
def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PACKAGER),
|
||||
"--root",
|
||||
str(source),
|
||||
"--output-dir",
|
||||
str(output),
|
||||
"--commit",
|
||||
"a" * 40,
|
||||
"--source-date-epoch",
|
||||
"1720000000",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_release_bundle_is_reproducible(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
_fixture(source)
|
||||
|
||||
first = _build(source, tmp_path / "first")
|
||||
second = _build(source, tmp_path / "second")
|
||||
|
||||
assert first.returncode == 0, first.stderr
|
||||
assert second.returncode == 0, second.stderr
|
||||
first_archive = next((tmp_path / "first").glob("*.tar.gz"))
|
||||
second_archive = next((tmp_path / "second").glob("*.tar.gz"))
|
||||
assert first_archive.read_bytes() == second_archive.read_bytes()
|
||||
|
||||
manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text())
|
||||
checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0]
|
||||
assert manifest["commit"] == "a" * 40
|
||||
assert manifest["artifact"]["sha256"] == checksum
|
||||
assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest()
|
||||
assert sorted(manifest["files"]) == [
|
||||
"README.md",
|
||||
"frontend/index.html",
|
||||
"requirements.txt",
|
||||
"src/main.py",
|
||||
]
|
||||
with tarfile.open(first_archive, "r:gz") as archive:
|
||||
names = archive.getnames()
|
||||
embedded = json.load(archive.extractfile("release-manifest.json"))
|
||||
assert names == [
|
||||
"README.md",
|
||||
"frontend/index.html",
|
||||
"release-manifest.json",
|
||||
"requirements.txt",
|
||||
"src/main.py",
|
||||
]
|
||||
assert embedded["commit"] == "a" * 40
|
||||
assert embedded["files"] == manifest["files"]
|
||||
|
||||
|
||||
def test_release_bundle_verifier_enforces_integrity(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
output = tmp_path / "dist"
|
||||
_fixture(source)
|
||||
built = _build(source, output)
|
||||
assert built.returncode == 0, built.stderr
|
||||
|
||||
valid = subprocess.run(
|
||||
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert valid.returncode == 0, valid.stderr
|
||||
|
||||
archive = next(output.glob("*.tar.gz"))
|
||||
archive.write_bytes(archive.read_bytes() + b"tampered")
|
||||
tampered = subprocess.run(
|
||||
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert tampered.returncode != 0
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user