Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
80417abd5b | ||
|
|
37d55e9b20 | ||
|
|
94cbde3cf9 | ||
|
|
fedddb246d | ||
|
|
e28472b268 | ||
|
|
3b85b4f35d | ||
|
|
997cb7dcd7 | ||
|
|
04bb6839ae | ||
|
|
1fdf6f417d | ||
|
|
cc38b3d0f6 | ||
|
|
b11ec6a732 | ||
|
|
e58808f2d4 | ||
|
|
e0543b2007 |
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,47 @@ inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API; mobile issue capture requires issue
|
||||
creation and assignment permission. The New issue sheet can optionally select an open
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
when search is unavailable. The sheet can also optionally select an open
|
||||
repository milestone and due date; the dashboard validates both and sends them with
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. Issue capture and authored mobile actions (issue
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. Results
|
||||
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. After a healthy, fully paginated
|
||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
||||
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
||||
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
||||
`updated_at` revisions make no detail request, transient failures retain the prior copy,
|
||||
and pull-request diffs remain online-only. Saved unread-update conversations remain
|
||||
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
|
||||
deduplicated acknowledgement to the durable background delivery system, removes the
|
||||
update from the local queue immediately, and opens the next saved conversation. A cold
|
||||
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
||||
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
|
||||
navigations are also deadline-bounded: after four seconds without a network response,
|
||||
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
||||
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
||||
Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
@@ -82,17 +115,52 @@ 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 revokes only the current session
|
||||
before clearing browser state, so a copied cookie cannot be replayed afterward;
|
||||
other signed-in devices remain active. 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
|
||||
and stylesheet are same-origin static assets included in the offline PWA shell.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
@@ -133,6 +201,12 @@ other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
|
||||
below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
|
||||
can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the
|
||||
sections that changed.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
||||
@@ -141,13 +215,17 @@ 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; `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
|
||||
@@ -160,11 +238,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
|
||||
@@ -233,6 +326,10 @@ python3 -m src.release_engine \
|
||||
--repo stackchain/stackchain-dashboard \
|
||||
--agent timmy \
|
||||
--issue 19 \
|
||||
--agent-timeout 1800 \
|
||||
--test-timeout 900 \
|
||||
--termination-grace 5 \
|
||||
--agent-env AGENT_CONFIG_HOME \
|
||||
--test-command 'python3 -m pytest tests/ -q'
|
||||
```
|
||||
|
||||
@@ -241,6 +338,42 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
|
||||
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
||||
do not run without `--issue` when processing a preselected ticket.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Full behavior and
|
||||
safety gates are documented in
|
||||
Agent and test commands use POSIX argument quoting and run as argument vectors,
|
||||
not through a shell. Issue context is available to the agent only through
|
||||
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
|
||||
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
|
||||
text. Shell substitutions, redirects, and pipelines are intentionally not
|
||||
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
|
||||
configure that script as `RELEASE_AGENT_COMMAND` instead.
|
||||
|
||||
Every child process receives an explicit least-privilege environment rather
|
||||
than inheriting the worker's credentials. Coding agents receive a minimal
|
||||
runtime environment plus the five `RELEASE_*` context values above. Tests and
|
||||
Git receive only the minimal runtime environment, so `GITEA_TOKEN` and
|
||||
unrelated parent secrets remain in the release-engine process. If an agent
|
||||
runtime needs another non-secret setting, allow it explicitly with a repeated
|
||||
`--agent-env NAME` option or the comma-separated `RELEASE_AGENT_ENV` variable.
|
||||
`GITEA_TOKEN`, reserved `RELEASE_*` names, and malformed names are rejected
|
||||
before the issue is claimed. Secret-bearing values must not be allowlisted.
|
||||
|
||||
Agent and test deadlines are independent. On expiry, the engine terminates the
|
||||
command's entire process group, escalates from `SIGTERM` to `SIGKILL` after the
|
||||
configured grace period, and records `agent_timed_out` or `tests_timed_out`
|
||||
without advancing the release. The equivalent environment variables are
|
||||
`RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and
|
||||
`RELEASE_TERMINATION_GRACE`; every value must be positive.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Successful checkpoints
|
||||
record the repository, issue branch, and exact commit SHA. A restart resumes at
|
||||
tests, push, or PR creation only after validating that identity and a clean
|
||||
worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations
|
||||
are serialized by an OS-backed, non-blocking lease at
|
||||
`.release-engine/run.lock` under the canonical repository path. If an hourly
|
||||
run overlaps a manual or slow prior run, the contender exits with `release run
|
||||
already active` and owner diagnostics before Gitea discovery or any Git/agent
|
||||
work. The kernel releases ownership when the process exits, so never delete a
|
||||
lock file to recover from a crash; stale file metadata cannot retain the lease.
|
||||
Checkpoint replacement uses writer-unique, flushed temporary files and an
|
||||
atomic rename, preventing concurrent writers from colliding or exposing partial
|
||||
JSON. Full behavior and safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
@@ -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.
|
||||
@@ -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,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
upsert,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
|
||||
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 +193,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 +247,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,7 +302,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await fetchJson(request.url, request.options);
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
@@ -229,7 +313,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 +326,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (purgeRequested) return { blocked: true };
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
await store.upsert(item);
|
||||
const claimed = await store.claim(item.id, currentLogin);
|
||||
@@ -245,8 +334,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 +343,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),
|
||||
|
||||
+66
-11
@@ -5,11 +5,16 @@ function createContextPoller({
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
setDeadlineTimer = setTimeout,
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
let retainedSnapshot = null;
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
@@ -25,36 +30,81 @@ function createContextPoller({
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
function abortError() {
|
||||
const error = new Error('Live update superseded.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function supersede(request) {
|
||||
if (!request || request.settled) return;
|
||||
request.superseded = true;
|
||||
request.controller.abort();
|
||||
request.rejectDeadline(abortError());
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
if (activeRequest && !options.force) return activeRequest.promise;
|
||||
if (activeRequest) supersede(activeRequest);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectDeadline;
|
||||
const deadlinePromise = new Promise((resolve, reject) => {
|
||||
rejectDeadline = reject;
|
||||
});
|
||||
const requestState = {
|
||||
controller,
|
||||
deadline: null,
|
||||
promise: null,
|
||||
rejectDeadline,
|
||||
settled: false,
|
||||
superseded: false,
|
||||
};
|
||||
activeRequest = requestState;
|
||||
requestState.deadline = setDeadlineTimer(() => {
|
||||
if (requestState.settled || requestState.superseded) return;
|
||||
controller.abort();
|
||||
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
|
||||
error.name = 'TimeoutError';
|
||||
rejectDeadline(error);
|
||||
}, timeoutMs);
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
|
||||
revisions = { ...revisions, ...(snapshot.revisions || {}) };
|
||||
onSnapshot(retainedSnapshot, changedSections);
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
if (activeRequest === requestState && !stopped) onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
requestState.settled = true;
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
return requestState.promise;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
return refresh({ force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -64,6 +114,11 @@ function createContextPoller({
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
if (activeRequest) {
|
||||
const request = activeRequest;
|
||||
activeRequest = null;
|
||||
supersede(request);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
@@ -140,6 +143,51 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
return { repository, title, key: repository + '\n' + title.toLowerCase() };
|
||||
}
|
||||
|
||||
async function findDuplicates(draft) {
|
||||
const input = duplicateKey(draft);
|
||||
const request = ++duplicateRequest;
|
||||
if (!input.repository || input.title.length < 4) {
|
||||
duplicateState = {status: 'idle', key: input.key, candidates: []};
|
||||
return duplicateState;
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {
|
||||
status: 'ready',
|
||||
key: input.key,
|
||||
partial: payload?.partial === true,
|
||||
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
|
||||
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
|
||||
).slice(0, 3),
|
||||
};
|
||||
return duplicateState;
|
||||
} catch (error) {
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
|
||||
return duplicateState;
|
||||
}
|
||||
}
|
||||
|
||||
function needsDuplicateAcknowledgement(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
|
||||
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
|
||||
}
|
||||
|
||||
function acknowledgeDuplicates(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
|
||||
acknowledgedDuplicateKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
@@ -166,7 +214,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, submit,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.offline-today-readiness[hidden] { display:none; }
|
||||
.offline-today-readiness button { min-height:44px; }
|
||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.install-app-card[hidden] { display:none; }
|
||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
||||
.install-app-card button { min-height:44px; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.later-picker[hidden] { display:none; }
|
||||
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.later-picker-header h2 { margin:0; }
|
||||
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
|
||||
.later-picker-error { min-height:1.4em; color:#fca5a5; }
|
||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-work[hidden] { display:none; }
|
||||
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-file.reviewed { opacity:.72; }
|
||||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||||
.review-note { min-height:88px; margin-top:6px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
|
||||
#continue-review-to-merge { min-height:44px; width:100%; }
|
||||
#continue-review-to-merge[hidden] { display:none; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:block; min-width:max-content; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.review-inline-actions button { min-height:44px; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; }
|
||||
.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; }
|
||||
.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; }
|
||||
.markdown-content ul { padding-left:24px; }
|
||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
|
||||
.create-issue-duplicates[hidden] { display:none; }
|
||||
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
|
||||
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
|
||||
.create-issue-duplicate-card a { color:#93c5fd; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.shared-content-actions button { min-height:44px; }
|
||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.pull-sheet.open { display:flex; }
|
||||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
|
||||
.pull-diff-line { display:block; width:max-content; min-width:100%; }
|
||||
.pull-diff-line.added { color:#86efac; background:#123422; }
|
||||
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
|
||||
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
|
||||
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
|
||||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
.app-menu { position:relative; margin-left:0; }
|
||||
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
|
||||
.app-menu > summary::-webkit-details-marker { display:none; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -113,7 +113,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued issue',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
@@ -144,7 +146,9 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
outbox_id: item.id,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : 'queued',
|
||||
label: item.status === 'attention' ? 'Needs attention' : 'Queued message',
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
|
||||
+125
-3129
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};
|
||||
});
|
||||
@@ -67,14 +67,29 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|
||||
updated = {
|
||||
...item,
|
||||
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}), mirror);
|
||||
return updated;
|
||||
@@ -138,6 +153,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
@@ -174,8 +190,18 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
update(id, item);
|
||||
const result = await sendItem({ ...item, status: 'queued' }, currentLogin);
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(operationId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return { confirmed: result.issue ? [result.issue] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
@@ -191,6 +217,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
if (background?.status === 'sent') return [];
|
||||
if (background?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
@@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
@@ -209,6 +215,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
|
||||
}).finally(() => { releaseRequest = null; });
|
||||
return releaseRequest;
|
||||
},
|
||||
handoff(item, recipient, currentLogin) {
|
||||
if (handoffRequest) return handoffRequest;
|
||||
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
!result.assignees.includes(recipient) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue handoff was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
|
||||
@@ -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,247 @@
|
||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
|
||||
const prefix = 'stackchain.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function validRecords(records) {
|
||||
return records && typeof records === 'object' && !Array.isArray(records);
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = { revision: plan.revision, records: plan.records };
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// Server truth remains usable in this tab when storage is unavailable.
|
||||
}
|
||||
onRemoteRecords?.(plan.records);
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation)
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
records.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
records.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return records.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => record.operation).filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt };
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('later');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(records) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/later');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/later', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Later delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : 'saved');
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'later' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
|
||||
+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; });
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
+16
-11
@@ -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;
|
||||
|
||||
@@ -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,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))
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -139,7 +139,11 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
|
||||
if (!result?.merged) {
|
||||
throw new Error(result?.confirmation_pending && result?.error
|
||||
? result.error
|
||||
: 'Pull request merge was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { mergeRequest = null; });
|
||||
return mergeRequest;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v19';
|
||||
const CACHE = 'stackchain-dashboard-shell-v54';
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
@@ -13,13 +16,21 @@ 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/today-work.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
@@ -27,41 +38,106 @@ const SHELL = [
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
|
||||
async function 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 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() {
|
||||
@@ -98,6 +174,14 @@ self.addEventListener('sync', event => {
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
@@ -121,9 +205,11 @@ self.addEventListener('fetch', event => {
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).then(async response => {
|
||||
fetchNavigation(request).then(async response => {
|
||||
const cache = await caches.open(CACHE);
|
||||
if (response.ok) {
|
||||
const responseUrl = new URL(response.url || request.url);
|
||||
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
|
||||
if (response.ok && !response.redirected && isDashboardShell) {
|
||||
await cache.put(BASE, response.clone());
|
||||
} else if (OUTAGE_STATUSES.has(response.status)) {
|
||||
const cached = await cache.match(BASE);
|
||||
@@ -132,7 +218,11 @@ self.addEventListener('fetch', event => {
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
return cache.match(BASE);
|
||||
const cached = await cache.match(BASE);
|
||||
return cached || new Response(
|
||||
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
|
||||
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
|
||||
+197
-6
@@ -13,24 +13,102 @@
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
confirmAction: message => root.confirm(message),
|
||||
promptAuthorization: details => root.prompt(
|
||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||
),
|
||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||
onClearError: error => {
|
||||
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||||
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||||
},
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
});
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
boundary.resumeQueuedWork();
|
||||
};
|
||||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
location.replace(base + 'login?reason=session-revoked');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
const entry = String(cookie?.() || '').split(';')
|
||||
@@ -44,7 +122,7 @@
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}) {
|
||||
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)) {
|
||||
@@ -54,7 +132,49 @@
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetchImpl(input, requestOptions);
|
||||
if (response.status === 401) onExpired();
|
||||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
});
|
||||
if (!authorization.ok) return authorization;
|
||||
const grant = await authorization.json().catch(() => ({}));
|
||||
if (!grant.grant) return response;
|
||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||||
retryHeaders.set('X-Step-Up-Grant', grant.grant);
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
|
||||
expirationStarted = true;
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else {
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -70,17 +190,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' });
|
||||
@@ -90,6 +262,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
await clearPrivateDeviceData();
|
||||
location.assign(base + 'login');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resumeQueuedWork() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
@@ -97,5 +279,14 @@
|
||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||||
}
|
||||
|
||||
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
|
||||
return {
|
||||
fetch: sessionFetch,
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
revokeActiveDevice,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
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']);
|
||||
|
||||
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,235 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({ revision: plan.revision, ids: plan.ids }));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
if (broadcast) channel?.postMessage({ revision: plan.revision, ids: plan.ids });
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
return [...keys].map(recordKey => JSON.parse(storage.getItem(recordKey) || 'null'))
|
||||
.filter(record => record?.operation)
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id))
|
||||
.map(record => record.operation)
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
)) {
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
const operation = { operation_id: operationId(), action, item_id: itemId, direction };
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: Date.now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
for (const id of ids || []) enqueue('add', id);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/today', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Today delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' : 'saved');
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
@@ -0,0 +1,105 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (ids.length) storage.setItem(key, JSON.stringify(ids));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
if (!id) return 'unavailable';
|
||||
const ids = read();
|
||||
if (ids.includes(id)) return 'exists';
|
||||
if (ids.length >= limit) return 'full';
|
||||
ids.push(id);
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return write(unique);
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
return next.length !== ids.length && write(next);
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (index < 0 || target < 0 || target >= ids.length) return false;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
return write(ids);
|
||||
}
|
||||
|
||||
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const ids = read();
|
||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) write(retained);
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
|
||||
function contains(item) {
|
||||
return read().includes(identity(item));
|
||||
}
|
||||
|
||||
function position(item) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
return {
|
||||
can_up: index > 0,
|
||||
can_down: index >= 0 && index < ids.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -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()
|
||||
+67
-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,18 +145,64 @@ 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:
|
||||
_session_store().revoke(session.session_id)
|
||||
|
||||
|
||||
async def revoke_all_sessions() -> None:
|
||||
await asyncio.to_thread(_session_store().revoke_all)
|
||||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def revoke_managed_session(management_id: str) -> bool:
|
||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||
|
||||
|
||||
async def issue_step_up(session: Session, *, action: str, target: str) -> str:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().mint_step_up,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
ttl_seconds=STEP_UP_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
async def consume_step_up(
|
||||
grant: str, session: Session, *, action: str, target: str
|
||||
) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().consume_step_up,
|
||||
grant,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
)
|
||||
|
||||
|
||||
async def request_session(request: Request) -> Session | None:
|
||||
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
async def request_session_verification(request: Request) -> SessionVerification:
|
||||
return await asyncio.to_thread(
|
||||
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
|
||||
)
|
||||
|
||||
|
||||
def cookie_path(request: Request) -> str:
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
return root_path or "/"
|
||||
|
||||
@@ -680,6 +680,21 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
latest_url = _safe_web_url(comment.get("html_url")) or _safe_web_url(
|
||||
subject.get("latest_comment_html_url")
|
||||
)
|
||||
assignee_values = subject_detail.get("assignees")
|
||||
assignees = [
|
||||
value.get("login")
|
||||
for value in (assignee_values if isinstance(assignee_values, list) else [])
|
||||
if isinstance(value, dict) and isinstance(value.get("login"), str)
|
||||
]
|
||||
issue = {
|
||||
"number": subject_detail.get("number"),
|
||||
"assignees": assignees,
|
||||
"claimable": (
|
||||
subject.get("type") == "Issue"
|
||||
and subject_detail.get("state", subject.get("state")) == "open"
|
||||
and not assignees
|
||||
),
|
||||
} if supported_conversation and subject.get("type") == "Issue" else None
|
||||
return {
|
||||
"id": thread_id,
|
||||
"repository": repository.get("full_name", "")
|
||||
@@ -710,6 +725,7 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
else "",
|
||||
"url": latest_url,
|
||||
},
|
||||
"issue": issue,
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
@@ -1038,6 +1054,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def issue_handoff_candidates(repository: str) -> list[dict]:
|
||||
user, response = await asyncio.gather(
|
||||
current_user(),
|
||||
_get_client().get(
|
||||
f"/api/v1/repos/{repository}/assignees", headers=_auth()
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea assignees response was not a list")
|
||||
current_login = user.get("login") if isinstance(user, dict) else None
|
||||
candidates = []
|
||||
for item in payload:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
login = item.get("login")
|
||||
if not isinstance(login, str) or not login or login == current_login:
|
||||
continue
|
||||
full_name = item.get("full_name")
|
||||
candidates.append({
|
||||
"login": login,
|
||||
"name": full_name if isinstance(full_name, str) and full_name else login,
|
||||
})
|
||||
return candidates
|
||||
|
||||
|
||||
async def handoff_assigned_issue(
|
||||
repository: str, number: int, recipient: str
|
||||
) -> dict:
|
||||
login, issue = await _current_login_and_target(
|
||||
f"repos/{repository}/issues/{number}"
|
||||
)
|
||||
if (
|
||||
issue.get("state") != "open"
|
||||
or issue.get("pull_request") is not None
|
||||
or not _login_in_users(login, issue.get("assignees"))
|
||||
):
|
||||
raise IssueNotAvailableError("Issue is not assigned to the current user")
|
||||
|
||||
eligible = {
|
||||
item["login"] for item in await issue_handoff_candidates(repository)
|
||||
}
|
||||
if recipient not in eligible:
|
||||
raise IssueNotAvailableError("Handoff recipient is not eligible")
|
||||
assignees_value = issue.get("assignees")
|
||||
assignees = assignees_value if isinstance(assignees_value, list) else []
|
||||
desired = [
|
||||
item["login"] for item in assignees
|
||||
if isinstance(item, dict)
|
||||
and isinstance(item.get("login"), str)
|
||||
and item["login"] != login
|
||||
]
|
||||
if recipient not in desired:
|
||||
desired.append(recipient)
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
headers=_auth(),
|
||||
json={"assignees": desired},
|
||||
)
|
||||
response.raise_for_status()
|
||||
confirmed = response.json()
|
||||
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
|
||||
confirmed_assignees = [
|
||||
item["login"] for item in confirmed_value
|
||||
if isinstance(item, dict) and isinstance(item.get("login"), str)
|
||||
] if isinstance(confirmed_value, list) else []
|
||||
if (
|
||||
not isinstance(confirmed, dict)
|
||||
or confirmed.get("number") != number
|
||||
or login in confirmed_assignees
|
||||
or recipient not in confirmed_assignees
|
||||
or set(confirmed_assignees) != set(desired)
|
||||
):
|
||||
raise ValueError("Gitea did not confirm issue handoff")
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": number,
|
||||
"state": confirmed.get("state", "open"),
|
||||
"assignees": confirmed_assignees,
|
||||
"recipient": recipient,
|
||||
}
|
||||
|
||||
|
||||
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/repos/{repository}/issues/{number}",
|
||||
@@ -1470,6 +1570,21 @@ async def pull_completion_review(repository: str, number: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def is_pull_merged_at_head(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> bool:
|
||||
pull = await fetch(f"repos/{repository}/pulls/{number}")
|
||||
if not isinstance(pull, dict):
|
||||
return False
|
||||
head_value = pull.get("head")
|
||||
head = head_value if isinstance(head_value, dict) else {}
|
||||
return (
|
||||
pull.get("merged") is True
|
||||
and pull.get("state") == "closed"
|
||||
and head.get("sha") == expected_head_sha
|
||||
)
|
||||
|
||||
|
||||
async def merge_assigned_pull(
|
||||
repository: str, number: int, expected_head_sha: str
|
||||
) -> dict:
|
||||
|
||||
+6
-2
@@ -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,190 @@
|
||||
"""Durable, account-scoped Later deferrals."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class LaterStore:
|
||||
def __init__(self, path: str | Path, *, timeout: float = 1.0):
|
||||
self.path = Path(path)
|
||||
self.timeout = timeout
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
records TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS later_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "records": {}}
|
||||
return {"revision": int(row[0]), "records": json.loads(row[1])}
|
||||
|
||||
@staticmethod
|
||||
def _validate_wake_at(wake_at: str | None) -> str:
|
||||
if not wake_at:
|
||||
raise ValueError("wake_at is required for defer")
|
||||
try:
|
||||
datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("wake_at must be an ISO timestamp") from error
|
||||
return wake_at
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
operation_id: str,
|
||||
action: str,
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"defer", "restore"}:
|
||||
raise ValueError("unsupported Later action")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
return snapshot
|
||||
|
||||
records = dict(snapshot["records"])
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
return {"revision": revision, "records": records}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply ordered deferrals with one SQLite write transaction."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
records = dict(snapshot["records"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
action = operation.get("action", "")
|
||||
item_id = operation.get("item_id", "")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"defer", "restore"}:
|
||||
raise ValueError("unsupported Later action")
|
||||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
revision += 1 if changed else 0
|
||||
connection.execute(
|
||||
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"records": records,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": [],
|
||||
}
|
||||
+619
-20
@@ -2,14 +2,17 @@ import asyncio
|
||||
import hmac
|
||||
import math
|
||||
import os
|
||||
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
|
||||
@@ -30,7 +33,10 @@ 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.later_store import LaterStore
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
from src.views import router as frontend_router
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -59,6 +65,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
@@ -98,6 +105,9 @@ _live_section_retry_at: dict[str, float | None] = {
|
||||
section: None for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_live_snapshot_refreshing_sections: set[str] = set()
|
||||
_live_section_revisions: dict[str, int] = {
|
||||
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
_read_notification_ids: set[int] = set()
|
||||
_authored_action_operations: dict[
|
||||
str, tuple[tuple[Any, ...], asyncio.Task, float]
|
||||
@@ -128,6 +138,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:
|
||||
@@ -144,10 +171,67 @@ 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
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -239,6 +323,12 @@ class IssueMilestoneUpdate(BaseModel):
|
||||
milestone_id: PositiveInt | None = None
|
||||
|
||||
|
||||
class IssueHandoff(BaseModel):
|
||||
recipient: str = Field(
|
||||
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
|
||||
)
|
||||
|
||||
|
||||
class PullReviewComment(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
body: str = Field(min_length=1, max_length=10_000)
|
||||
@@ -304,9 +394,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(
|
||||
@@ -443,6 +546,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)
|
||||
@@ -471,9 +592,12 @@ async def require_operator_session(request: Request, call_next):
|
||||
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"},
|
||||
@@ -482,12 +606,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", "")
|
||||
@@ -513,7 +649,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 (
|
||||
@@ -530,6 +666,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."""
|
||||
@@ -546,7 +729,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"},
|
||||
@@ -567,7 +750,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"},
|
||||
@@ -576,7 +759,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"},
|
||||
@@ -584,7 +767,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"},
|
||||
@@ -615,6 +800,68 @@ 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
|
||||
@@ -624,11 +871,120 @@ async def session_status(request: Request):
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
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"},
|
||||
@@ -654,6 +1010,109 @@ 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,
|
||||
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:
|
||||
return JSONResponse(
|
||||
{"detail": "Session registry is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
path = dashboard_auth.cookie_path(request)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.SESSION_COOKIE,
|
||||
path=path,
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
response.delete_cookie(
|
||||
dashboard_auth.CSRF_COOKIE,
|
||||
path=path,
|
||||
secure=True,
|
||||
httponly=False,
|
||||
samesite="strict",
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {
|
||||
"authenticated": False,
|
||||
"all_sessions_revoked": True,
|
||||
"clear_private_device_data": True,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readiness():
|
||||
"""Return readiness after verifying the configured Gitea connection."""
|
||||
@@ -1076,8 +1535,19 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
|
||||
_live_section_retry_at[section] = None
|
||||
else:
|
||||
_record_live_section_failure(section)
|
||||
result = _merge_live_snapshot(_live_snapshot_value, refreshed)
|
||||
previous = _live_snapshot_value
|
||||
result = _merge_live_snapshot(previous, refreshed)
|
||||
result = _without_read_notifications(result)
|
||||
for section in sections:
|
||||
if section not in refreshed_states:
|
||||
continue
|
||||
changed = previous is None or previous.get(section) != result.get(section)
|
||||
if section == "notifications":
|
||||
changed = changed or previous is None or previous.get(
|
||||
"notification_pagination"
|
||||
) != result.get("notification_pagination")
|
||||
if changed:
|
||||
_live_section_revisions[section] += 1
|
||||
_live_snapshot_value = result
|
||||
successful_times = [value for value in _live_section_created_at.values() if value is not None]
|
||||
_live_snapshot_created_at = max(successful_times) if successful_times else None
|
||||
@@ -1100,7 +1570,13 @@ def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
|
||||
return _live_snapshot_task
|
||||
|
||||
|
||||
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
|
||||
def _live_snapshot_payload(
|
||||
value: dict,
|
||||
*,
|
||||
stale: bool,
|
||||
revalidating: bool,
|
||||
known_revisions: dict[str, int | None] | None = None,
|
||||
) -> dict:
|
||||
payload = dict(value)
|
||||
now = time.monotonic()
|
||||
section_freshness = {}
|
||||
@@ -1132,6 +1608,13 @@ def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> d
|
||||
"retry_in_seconds": min(retries, default=0),
|
||||
"sections": section_freshness,
|
||||
}
|
||||
payload["revisions"] = dict(_live_section_revisions)
|
||||
for section, known_revision in (known_revisions or {}).items():
|
||||
if known_revision is None or known_revision != _live_section_revisions[section]:
|
||||
continue
|
||||
payload.pop(section, None)
|
||||
if section == "notifications":
|
||||
payload.pop("notification_pagination", None)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1149,6 +1632,8 @@ def _remove_notification_from_live_snapshot(thread_id: int) -> None:
|
||||
for notification in retained_notifications
|
||||
if not isinstance(notification, dict) or notification.get("id") != thread_id
|
||||
]
|
||||
if updated["notifications"] != retained_notifications:
|
||||
_live_section_revisions["notifications"] += 1
|
||||
_live_snapshot_value = updated
|
||||
|
||||
|
||||
@@ -1174,9 +1659,18 @@ def _without_read_notifications(snapshot: dict) -> dict:
|
||||
|
||||
|
||||
@app.get("/api/v1/live")
|
||||
async def live_snapshot() -> JSONResponse:
|
||||
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),
|
||||
) -> JSONResponse:
|
||||
"""Return a freshness-bounded snapshot and share identical upstream loads."""
|
||||
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
|
||||
known_revisions = {
|
||||
"context": context_revision,
|
||||
"events": events_revision,
|
||||
"notifications": notifications_revision,
|
||||
}
|
||||
now = time.monotonic()
|
||||
due_sections = _due_live_sections(now)
|
||||
if (
|
||||
@@ -1191,19 +1685,28 @@ async def live_snapshot() -> JSONResponse:
|
||||
for state in _live_snapshot_value.get("sections", {}).values()
|
||||
),
|
||||
revalidating=False,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
task = _start_live_snapshot_refresh(due_sections)
|
||||
if _live_snapshot_value is not None:
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(
|
||||
_live_snapshot_value, stale=True, revalidating=True
|
||||
_live_snapshot_value,
|
||||
stale=True,
|
||||
revalidating=True,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await asyncio.shield(task)
|
||||
return JSONResponse(
|
||||
_live_snapshot_payload(result, stale=False, revalidating=False)
|
||||
_live_snapshot_payload(
|
||||
result,
|
||||
stale=False,
|
||||
revalidating=False,
|
||||
known_revisions=known_revisions,
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
@@ -1434,7 +1937,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:
|
||||
@@ -1478,6 +1981,61 @@ async def release_assigned_issue(
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
|
||||
async def issue_handoff_candidates(
|
||||
owner: str, repo: str, number: int = PathParam(gt=0)
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_candidates():
|
||||
if not await gitea_proxy.is_assigned_issue(repository, number):
|
||||
raise HTTPException(status_code=404, detail="Assigned issue not found")
|
||||
return await gitea_proxy.issue_handoff_candidates(repository)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Teammates could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
|
||||
async def handoff_assigned_issue(
|
||||
handoff: IssueHandoff,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.handoff_assigned_issue(
|
||||
repository, number, handoff.recipient
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except gitea_proxy.IssueNotAvailableError:
|
||||
return JSONResponse(
|
||||
{"error": "The issue or recipient changed. Reload before handing off."},
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
|
||||
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
|
||||
repository = f"{owner}/{repo}"
|
||||
@@ -1804,8 +2362,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):
|
||||
@@ -2038,11 +2610,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):
|
||||
@@ -2068,9 +2650,26 @@ async def merge_assigned_pull(
|
||||
status_code=409,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
merged = await asyncio.wait_for(
|
||||
gitea_proxy.is_pull_merged_at_head(
|
||||
repository, number, submission.expected_head_sha
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
merged = False
|
||||
if merged:
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
return JSONResponse(
|
||||
{"error": "The pull request could not be merged. It remains in My Work; please retry."},
|
||||
status_code=503,
|
||||
{
|
||||
"number": number,
|
||||
"merged": False,
|
||||
"state": "unknown",
|
||||
"confirmation_pending": True,
|
||||
"error": "Merge confirmation is pending. Check its status before retrying.",
|
||||
},
|
||||
status_code=202,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
+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})
|
||||
+205
-5
@@ -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,10 +149,140 @@ 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),),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
|
||||
now = int(self.clock())
|
||||
current_hash = self._digest(current_session_id)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT management_id, device_label, created_at, expires_at, session_hash "
|
||||
"FROM active_sessions WHERE expires_at > ? "
|
||||
"ORDER BY expires_at DESC, created_at DESC",
|
||||
(now,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return [
|
||||
ActiveDevice(
|
||||
management_id=row[0],
|
||||
device_label=row[1],
|
||||
created_at=row[2],
|
||||
expires_at=row[3],
|
||||
current=secrets.compare_digest(row[4], current_hash),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def mint_step_up(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
ttl_seconds: int,
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
grant = secrets.token_urlsafe(32)
|
||||
session_hash = self._digest(session_id)
|
||||
try:
|
||||
with self._connect(initialize=True) as connection:
|
||||
active = connection.execute(
|
||||
"SELECT 1 FROM active_sessions "
|
||||
"WHERE session_hash = ? AND expires_at > ?",
|
||||
(session_hash, now),
|
||||
).fetchone()
|
||||
if active is None:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO step_up_grants("
|
||||
"grant_hash, session_hash, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(grant),
|
||||
session_hash,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
except SessionStoreError:
|
||||
raise
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
return grant
|
||||
|
||||
def consume_step_up(
|
||||
self,
|
||||
grant: str,
|
||||
session_id: str,
|
||||
*,
|
||||
action: str,
|
||||
target: str,
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE grant_hash = ? "
|
||||
"AND session_hash = ? AND action = ? AND target = ? "
|
||||
"AND expires_at > ? AND EXISTS ("
|
||||
"SELECT 1 FROM active_sessions "
|
||||
"WHERE active_sessions.session_hash = step_up_grants.session_hash "
|
||||
"AND active_sessions.expires_at > ?)",
|
||||
(
|
||||
self._digest(grant),
|
||||
self._digest(session_id),
|
||||
action,
|
||||
target,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Durable, account-scoped ordered Today plans."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TodayPlanFull(ValueError):
|
||||
"""Raised when an add would exceed the bounded Today plan."""
|
||||
|
||||
|
||||
class TodayStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_plans (
|
||||
login TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
login TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
PRIMARY KEY (login, operation_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _normalize_login(login: str) -> str:
|
||||
normalized = login.strip().lower()
|
||||
if not normalized:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
if row is None:
|
||||
return {"revision": 0, "ids": []}
|
||||
return {"revision": int(row[0]), "ids": json.loads(row[1])}
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
|
||||
def apply(
|
||||
self,
|
||||
login: str,
|
||||
operation_id: str,
|
||||
action: str,
|
||||
item_id: str,
|
||||
*,
|
||||
direction: str | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
return snapshot
|
||||
|
||||
ids = list(snapshot["ids"])
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
if len(ids) >= self.limit:
|
||||
raise TodayPlanFull("Today is limited to five items")
|
||||
ids.append(item_id)
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
|
||||
revision = snapshot["revision"] + (1 if changed else 0)
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, json.dumps(ids, separators=(",", ":"))),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
return {"revision": revision, "ids": ids}
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
login = self._normalize_login(login)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
rejected: list[dict[str, str]] = []
|
||||
|
||||
for operation in operations:
|
||||
operation_id = operation.get("operation_id", "")
|
||||
action = operation.get("action", "")
|
||||
item_id = operation.get("item_id", "")
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
duplicates.append(operation_id)
|
||||
continue
|
||||
|
||||
changed = False
|
||||
if action == "add":
|
||||
if item_id not in ids:
|
||||
if len(ids) >= self.limit:
|
||||
rejected.append({"operation_id": operation_id, "reason": "today_full"})
|
||||
continue
|
||||
ids.append(item_id)
|
||||
changed = True
|
||||
elif action == "remove":
|
||||
if item_id in ids:
|
||||
ids.remove(item_id)
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
index = ids.index(item_id)
|
||||
except ValueError:
|
||||
index = -1
|
||||
target = index - 1 if direction == "up" else index + 1
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
revision += 1 if changed else 0
|
||||
connection.execute(
|
||||
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||
(login, operation_id),
|
||||
)
|
||||
accepted.append(operation_id)
|
||||
|
||||
serialized = json.dumps(ids, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||
(login, revision, serialized),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||
(revision, serialized, login),
|
||||
)
|
||||
return {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"accepted_operation_ids": accepted,
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
+2
-2
@@ -13,8 +13,8 @@ LOGIN_HTML = """<!doctype html>
|
||||
<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):
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.views import dashboard as dashboard_html
|
||||
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
|
||||
|
||||
def dashboard_bundle_text() -> str:
|
||||
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
|
||||
return "\n".join(
|
||||
(
|
||||
(FRONTEND / "index.html").read_text(),
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def dashboard() -> str:
|
||||
# Exercise the real view lookup before adding its separately served assets.
|
||||
html = await dashboard_html()
|
||||
return "\n".join(
|
||||
(
|
||||
html,
|
||||
(FRONTEND / "dashboard.css").read_text(),
|
||||
(FRONTEND / "dashboard.js").read_text(),
|
||||
)
|
||||
)
|
||||
@@ -2,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
|
||||
|
||||
@@ -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"
|
||||
@@ -194,6 +194,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 +359,203 @@ const fetchJson = async url => {{
|
||||
assert output["error"] == "Gitea unavailable"
|
||||
|
||||
|
||||
def test_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
||||
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
|
||||
const store = {{
|
||||
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => {{state.released.push(id); state.queued=true;}},
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
state.keys.push(options.headers['Idempotency-Key']);
|
||||
state.attempts += 1;
|
||||
if (state.attempts === 1) return new Promise(() => {{}});
|
||||
return {{repository:'o/r',number:9}};
|
||||
}};
|
||||
(async () => {{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
|
||||
let firstError='';
|
||||
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
|
||||
const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Background request timed out."
|
||||
assert output["state"]["released"] == ["capture-timeout"]
|
||||
assert output["state"]["completed"] == ["capture-timeout"]
|
||||
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
|
||||
assert output["second"]["confirmed"][0]["number"] == 9
|
||||
|
||||
|
||||
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items=[
|
||||
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
|
||||
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
|
||||
];
|
||||
const state={{released:[],closed:0,mutations:0}};
|
||||
let mutationStarted;
|
||||
const started=new Promise(resolve => mutationStarted=resolve);
|
||||
const store={{
|
||||
claimBatch:async()=>items.map(item=>({{...item}})),
|
||||
complete:async()=>{{}},
|
||||
release:async id=>state.released.push(id),
|
||||
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
|
||||
}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
|
||||
sync.flush().catch(()=>{{}});
|
||||
await started;
|
||||
const outcome=await Promise.race([
|
||||
sync.purge().then(()=> 'purged'),
|
||||
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{state,outcome}}));
|
||||
process.exit(0);
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outcome"] == "purged"
|
||||
assert output["state"] == {
|
||||
"released": ["capture-purge-1", "capture-purge-2"],
|
||||
"closed": 1,
|
||||
"mutations": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = [
|
||||
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
|
||||
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
|
||||
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
|
||||
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
|
||||
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
|
||||
];
|
||||
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
|
||||
const store = {{
|
||||
claimBatch: async (owner, limit) => items.slice(0, limit),
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id),
|
||||
fail: async () => {{}}, countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key = options.headers['Idempotency-Key'];
|
||||
state.attempted.push(key); state.active += 1;
|
||||
state.maxActive = Math.max(state.maxActive, state.active);
|
||||
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
|
||||
state.active -= 1;
|
||||
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
|
||||
return {{id:key,number:7}};
|
||||
}};
|
||||
(async () => {{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{
|
||||
store,fetchJson,maxConcurrency:3,batchSize:10,
|
||||
batch: async work => {{ state.batches += 1; return work(); }},
|
||||
}}).flush(); }}
|
||||
catch (caught) {{ error=caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["maxActive"] == 3
|
||||
assert output["state"]["batches"] == 1
|
||||
assert sorted(output["state"]["attempted"]) == [
|
||||
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
|
||||
]
|
||||
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
|
||||
assert sorted(output["state"]["completed"]) == [
|
||||
"issue-1", "issue-2", "message-1", "message-2"
|
||||
]
|
||||
assert output["state"]["released"] == ["issue-bad"]
|
||||
assert output["error"] == "Temporary outage"
|
||||
|
||||
|
||||
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const items = Array.from({{length:5}}, (_, index) => ({{
|
||||
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
|
||||
repository:'o/r',title:'Item',body:'',labelIds:[],
|
||||
}}));
|
||||
const state = {{attempted:[],released:[],completed:[]}};
|
||||
const store = {{
|
||||
claimBatch: async () => items,
|
||||
complete: async id => state.completed.push(id),
|
||||
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson = async (url, options={{}}) => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
|
||||
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
return {{number:1}};
|
||||
}};
|
||||
(async()=>{{
|
||||
let error;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
|
||||
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["attempted"] == ["item-0", "item-1"]
|
||||
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
|
||||
assert output["state"]["completed"] == ["item-1"]
|
||||
assert output["error"] == {"message": "Authentication required", "status": 401}
|
||||
|
||||
|
||||
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100,claimMs:5000}});
|
||||
await store.reconcile([
|
||||
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'issue');
|
||||
await store.reconcile([
|
||||
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'authored');
|
||||
const claimed=await store.claimBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["claimed"]] == [
|
||||
"issue-1", "message-1", "issue-2", "message-2"
|
||||
]
|
||||
assert all(item["status"] == "sending" for item in output["claimed"])
|
||||
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
||||
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -355,6 +585,31 @@ const fetchJson = async url => {{
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_uncertain_background_delivery_preserves_verification_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let claimed=false; const state={{failed:[]}};
|
||||
const store={{
|
||||
claimNext:async()=>claimed?null:(claimed=true,{{id:'maybe',operationId:'maybe',ownerLogin:'timmy',repository:'o/r',title:'Maybe',labelIds:[]}}),
|
||||
fail:async(id,message,deliveryState)=>state.failed.push({{id,message,deliveryState}}),
|
||||
release:async()=>{{}}, countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error=new Error('Verify first'); error.status=422; error.code='delivery_uncertain'; throw error;
|
||||
}};
|
||||
(async()=>{{
|
||||
await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["failed"] == [
|
||||
{"id": "maybe", "message": "Verify first", "deliveryState": "uncertain"}
|
||||
]
|
||||
|
||||
|
||||
def test_issue_store_atomically_grants_one_delivery_claim():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -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,61 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
|
||||
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
const listeners = new Map();
|
||||
const viewport = {{
|
||||
height: 430, offsetTop: 17,
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: (name, fn) => {{ if (listeners.get(name) === fn) listeners.delete(name); }},
|
||||
}};
|
||||
const values = new Map();
|
||||
const palette = {{ style: {{
|
||||
setProperty: (name, value) => values.set(name, value),
|
||||
removeProperty: name => values.delete(name),
|
||||
}} }};
|
||||
const results = {{ scrollTop: 73 }};
|
||||
const controller = createMobileSearchViewport({{
|
||||
palette, results, viewport, mediaQuery: {{ matches: true }},
|
||||
schedule: fn => fn(),
|
||||
}});
|
||||
controller.open();
|
||||
if (values.get('--search-viewport-top') !== '17px') throw new Error('viewport offset was not applied');
|
||||
if (values.get('--search-viewport-height') !== '430px') throw new Error('viewport height was not applied');
|
||||
if (listeners.size !== 2) throw new Error('viewport listeners were not attached once');
|
||||
controller.rememberScroll();
|
||||
results.scrollTop = 0;
|
||||
controller.restoreScroll();
|
||||
if (results.scrollTop !== 73) throw new Error('result scroll position was not restored');
|
||||
controller.open();
|
||||
if (listeners.size !== 2) throw new Error('duplicate viewport listeners were attached');
|
||||
controller.close();
|
||||
if (listeners.size !== 0 || values.size !== 0) throw new Error('viewport state leaked after close');
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_mobile_search_is_a_keyboard_safe_workspace_wired_to_preview_history():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'class="cmd-palette-header"' in html
|
||||
assert 'id="close-command-palette"' in html
|
||||
assert 'style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"' not in html
|
||||
assert 'src="static/mobile-search-viewport.js"' in html
|
||||
assert "height:var(--search-viewport-height,100dvh)" in css
|
||||
assert "top:var(--search-viewport-top,0px)" in css
|
||||
assert "#cmd-results { flex:1; min-height:0; overflow-y:auto;" in css
|
||||
assert "mobileSearchViewport.rememberScroll()" in html
|
||||
assert "mobileSearchViewport.restoreScroll()" in html
|
||||
assert "mobileSearchViewport.open()" in html
|
||||
assert "mobileSearchViewport.close()" in html
|
||||
@@ -63,3 +63,164 @@ const poller = createContextPoller({{
|
||||
"callsWhileHidden": 1,
|
||||
"callsAfterResume": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_sends_revisions_and_merges_changed_sections():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const requested = [];
|
||||
const rendered = [];
|
||||
const responses = [
|
||||
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
|
||||
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
|
||||
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
|
||||
freshness: {{ age_seconds: 1 }} }},
|
||||
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
|
||||
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
|
||||
];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
|
||||
onSnapshot: (snapshot, changed) => rendered.push({{
|
||||
context: snapshot.context.user.login,
|
||||
event: snapshot.events[0].id,
|
||||
age: snapshot.freshness.age_seconds,
|
||||
changed,
|
||||
}}),
|
||||
onError: error => {{ throw error; }},
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
await poller.refresh();
|
||||
process.stdout.write(JSON.stringify({{ requested, rendered }}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"requested": [
|
||||
{},
|
||||
{"context": 1, "events": 1, "notifications": 1},
|
||||
{"context": 1, "events": 2, "notifications": 1},
|
||||
],
|
||||
"rendered": [
|
||||
{"context": "timmy", "event": 1, "age": 0,
|
||||
"changed": ["context", "events", "notifications"]},
|
||||
{"context": "timmy", "event": 2, "age": 1,
|
||||
"changed": ["events"]},
|
||||
{"context": "timmy", "event": 2, "age": 2, "changed": []},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
const timers = [];
|
||||
const deadlineTimers = [];
|
||||
const errors = [];
|
||||
const snapshots = [];
|
||||
const signals = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
signals.push(options.signal);
|
||||
if (calls === 1) return new Promise(() => {{}});
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
|
||||
onError: error => errors.push({{ name: error.name, message: error.message }}),
|
||||
setTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearTimer: timer => {{ timer.cancelled = true; }},
|
||||
setDeadlineTimer: (callback, delay) => {{
|
||||
const timer = {{ callback, delay, cancelled: false }};
|
||||
deadlineTimers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
|
||||
intervalMs: 8000,
|
||||
timeoutMs: 25,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
|
||||
deadline.callback();
|
||||
await first;
|
||||
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
|
||||
retry.callback();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: signals[0].aborted,
|
||||
errors,
|
||||
snapshots,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"errors": [
|
||||
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
|
||||
],
|
||||
"snapshots": ["timmy"],
|
||||
}
|
||||
|
||||
|
||||
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let resolveFirst;
|
||||
let firstSignal;
|
||||
const rendered = [];
|
||||
const errors = [];
|
||||
let calls = 0;
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: (revisions, options) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) {{
|
||||
firstSignal = options.signal;
|
||||
return new Promise(resolve => {{ resolveFirst = resolve; }});
|
||||
}}
|
||||
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
|
||||
}},
|
||||
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
|
||||
onError: error => errors.push(error.name),
|
||||
setTimer: () => 1,
|
||||
clearTimer: () => {{}},
|
||||
setDeadlineTimer: () => 1,
|
||||
clearDeadlineTimer: () => {{}},
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const stale = poller.start();
|
||||
const fresh = poller.refresh({{ force: true }});
|
||||
await fresh;
|
||||
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
|
||||
await stale;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
firstAborted: firstSignal.aborted,
|
||||
rendered,
|
||||
errors,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": 2,
|
||||
"firstAborted": True,
|
||||
"rendered": ["fresh"],
|
||||
"errors": [],
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -19,6 +20,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 +118,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)
|
||||
@@ -219,6 +274,203 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
|
||||
assert "correct horse battery staple" not in response.text
|
||||
|
||||
|
||||
@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
|
||||
@@ -276,6 +528,102 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_revokes_every_existing_session(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
|
||||
grant = await fresh_grant(phone, "revoke_all_sessions", "all")
|
||||
response = await phone.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_private = await phone.get("/api/v1/background-identity")
|
||||
laptop_private = await laptop.get("/api/v1/background-identity")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"authenticated": False,
|
||||
"all_sessions_revoked": True,
|
||||
"clear_private_device_data": True,
|
||||
}
|
||||
assert phone_private.status_code == 401
|
||||
assert laptop_private.status_code == 401
|
||||
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://evil.example",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
session = await client.get("/api/v1/session")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert session.status_code == 200
|
||||
assert session.json()["authenticated"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
|
||||
class BrokenStore:
|
||||
def is_active(self, session_id, expires_at):
|
||||
return True
|
||||
|
||||
def consume_step_up(self, grant, session_id, *, action, target):
|
||||
return True
|
||||
|
||||
def revoke_all(self):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": csrf,
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers.get_list("set-cookie") == []
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
|
||||
calls = 0
|
||||
@@ -344,6 +692,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
|
||||
@@ -381,7 +894,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())
|
||||
|
||||
@@ -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,7 +14,7 @@ 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: [] }};
|
||||
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
|
||||
const storage = {{
|
||||
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
|
||||
get length() {{ return this.values.size; }},
|
||||
@@ -26,15 +26,35 @@ 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 }});
|
||||
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),
|
||||
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); }});
|
||||
"""
|
||||
@@ -53,6 +73,105 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
|
||||
|
||||
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responses = [
|
||||
{status:428, payload:{detail:{code:'step_up_required', action:'merge_pull', target:'stackchain/api#7'}}},
|
||||
{status:201, payload:{grant:'one-time-grant', expires_in:90}},
|
||||
{status:200, payload:{merged:true}},
|
||||
];
|
||||
const original = { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({expected_head_sha:'abc123'}) };
|
||||
const response = await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/pulls/7/merge', original);
|
||||
state.finalStatus = response.status;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["finalStatus"] == 200
|
||||
assert result["prompts"] == [
|
||||
{"action": "merge_pull", "target": "stackchain/api#7"}
|
||||
]
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
"/dashboard/api/v1/fresh-authorization",
|
||||
"/dashboard/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
]
|
||||
assert json.loads(result["requests"][1]["body"]) == {
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
assert result["requests"][2]["headers"]["x-step-up-grant"] == "one-time-grant"
|
||||
assert result["requests"][2]["body"] == result["requests"][0]["body"]
|
||||
|
||||
|
||||
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await Promise.all([
|
||||
boundary.fetch('/dashboard/api/v1/live'),
|
||||
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
|
||||
]);
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == [
|
||||
"/dashboard/login?reason=session-expired"
|
||||
]
|
||||
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
|
||||
assert result["deletedDatabases"] == []
|
||||
assert result["deletedCaches"] == []
|
||||
|
||||
|
||||
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
state.responsePayload = {detail:'Authentication required', code:'session_revoked'};
|
||||
await boundary.fetch('/dashboard/api/v1/live');
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_worker_revocation_message_clears_window_storage_before_login():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-revoked'}});
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
|
||||
|
||||
|
||||
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responseStatus = 401;
|
||||
await boundary.fetch('https://untrusted.example/api/private');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["replaced"] == []
|
||||
|
||||
|
||||
def test_sign_out_clears_only_dashboard_private_device_state():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -71,6 +190,65 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
assert result["assignedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.failDeletion = true;
|
||||
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["assigned"] == ""
|
||||
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
|
||||
assert result["signOutError"] == "Could not clear private queued work from this device."
|
||||
|
||||
|
||||
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
await boundary.signOutAllDevices();
|
||||
state.remaining = Array.from(storage.values.keys());
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["confirmations"] == [
|
||||
"Sign out every device? You will need to sign in again everywhere."
|
||||
]
|
||||
request = result["requests"][0]
|
||||
assert request["url"] == "/dashboard/api/v1/sessions"
|
||||
assert request["method"] == "DELETE"
|
||||
assert request["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
|
||||
def test_active_devices_can_be_loaded_and_one_remote_device_revoked():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {devices:[
|
||||
{management_id:'remote-id-123456789', device_label:'Pixel <script>', created_at:1000, expires_at:2000, current:false},
|
||||
{management_id:'current-id-12345678', device_label:'Work laptop', created_at:1100, expires_at:2100, current:true},
|
||||
]};
|
||||
const devices = await boundary.listActiveDevices();
|
||||
await boundary.revokeActiveDevice(devices[0]);
|
||||
state.devices = devices;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["devices"][0]["device_label"] == "Pixel <script>"
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/sessions"
|
||||
assert result["requests"][1]["url"] == (
|
||||
"/dashboard/api/v1/sessions/remote-id-123456789"
|
||||
)
|
||||
assert result["requests"][1]["method"] == "DELETE"
|
||||
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["confirmations"] == ["Sign out Pixel <script>?"]
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
@@ -91,3 +269,8 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert '<script src="static/session.js"></script>' in html
|
||||
assert html.index('static/session.js') < html.index('static/markdown.js')
|
||||
assert '<button id="sign-out" type="button">Sign out & clear this device</button>' in html
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '<button id="active-devices" type="button">Active devices</button>' in html
|
||||
assert 'id="active-devices-sheet"' in html
|
||||
assert 'aria-label="Active devices"' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
+20
-1
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
|
||||
@@ -121,6 +121,22 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert output[1]["label"] == "Queued issue"
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_uncertain_delivery_for_explicit_user_verification():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:7,body:'Possibly posted',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',error:'Verify it was not posted before retrying.',queuedAt:200}}
|
||||
]}})]]);
|
||||
const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const drafts = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list();
|
||||
process.stdout.write(JSON.stringify(drafts[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["delivery_state"] == "uncertain"
|
||||
assert output["label"] == "Verify delivery"
|
||||
|
||||
|
||||
def test_draft_inbox_marks_mismatched_and_legacy_outbox_content_copy_only():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -160,6 +176,9 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+159
-1
@@ -915,7 +915,7 @@ async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflic
|
||||
assert response.status_code == 409
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"error": "This issue was already claimed or is no longer open. Refresh Find Work."
|
||||
"error": "This issue was already claimed or is no longer open."
|
||||
}
|
||||
|
||||
|
||||
@@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
|
||||
assert result["available"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
|
||||
async def handler(request):
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "timmy", "full_name": "Timmy"},
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": ""},
|
||||
{"login": ""},
|
||||
{"full_name": "Missing login"},
|
||||
"malformed",
|
||||
])
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [
|
||||
{"login": "alex", "name": "Alexander"},
|
||||
{"login": "casey", "name": "casey"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path == "/api/v1/user":
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.url.path.endswith("/assignees"):
|
||||
return httpx.Response(200, json=[
|
||||
{"login": "alex", "full_name": "Alexander"},
|
||||
{"login": "casey", "full_name": "Casey"},
|
||||
])
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open", "pull_request": None,
|
||||
"assignees": [{"login": "timmy"}, {"login": "casey"}],
|
||||
})
|
||||
assert request.content == b'{"assignees":["casey","alex"]}'
|
||||
return httpx.Response(200, json={
|
||||
"number": 17, "state": "open",
|
||||
"assignees": [{"login": "casey"}, {"login": "alex"}],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {
|
||||
"repository": "stackchain/api", "number": 17, "state": "open",
|
||||
"assignees": ["casey", "alex"], "recipient": "alex",
|
||||
}
|
||||
assert [(request.method, request.url.path) for request in requests] == [
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
("GET", "/api/v1/user"),
|
||||
("GET", "/api/v1/repos/stackchain/api/assignees"),
|
||||
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
|
||||
calls = []
|
||||
@@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
|
||||
assert main._available_issue_snapshot_created_at is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
calls.append(("assigned", repository, number))
|
||||
return True
|
||||
|
||||
async def candidates(repository):
|
||||
calls.append(("candidates", repository))
|
||||
return [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
async def handoff(repository, number, recipient):
|
||||
calls.append(("handoff", repository, number, recipient))
|
||||
return {
|
||||
"repository": repository, "number": number, "state": "open",
|
||||
"assignees": [recipient], "recipient": recipient,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
|
||||
)
|
||||
transferred = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff",
|
||||
json={"recipient": "alex"},
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
|
||||
assert transferred.status_code == 200
|
||||
assert transferred.json()["recipient"] == "alex"
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert transferred.headers["cache-control"] == "no-store"
|
||||
assert calls == [
|
||||
("assigned", "stackchain/api", 17),
|
||||
("candidates", "stackchain/api"),
|
||||
("handoff", "stackchain/api", 17, "alex"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
|
||||
async def handoff(_repository, _number, _recipient):
|
||||
raise gitea_proxy.IssueNotAvailableError("stale")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff",
|
||||
json={"recipient": "alex"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": "The issue or recipient changed. Reload before handing off."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
|
||||
called = False
|
||||
|
||||
async def assigned(_repository, _number):
|
||||
return False
|
||||
|
||||
async def candidates(_repository):
|
||||
nonlocal called
|
||||
called = True
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert called is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
|
||||
@@ -110,9 +110,10 @@ const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
let invalid = true;
|
||||
let operationSequence = 0;
|
||||
const calls = [];
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
|
||||
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
|
||||
@@ -132,12 +133,74 @@ outbox.flush('timmy').then(async () => {{
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["error"] == "Title is invalid"
|
||||
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
|
||||
assert [call["key"] for call in output["calls"]] == ["edit-1", "edit-2"]
|
||||
assert output["calls"][1]["body"]["title"] == "Fixed title"
|
||||
assert output["result"]["confirmed"][0]["number"] == 42
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rekeys_only_after_user_retries_uncertain_delivery():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); let sequence=0; const calls=[];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=> 'operation-' + (++sequence),
|
||||
fetchJson:async (_url, options) => {{
|
||||
const key=options.headers['Idempotency-Key']; calls.push(key);
|
||||
if (key === 'operation-1') {{ const e=new Error('Verify it was not posted before retrying.'); e.status=422; e.code='delivery_uncertain'; throw e; }}
|
||||
return {{number:319}};
|
||||
}},
|
||||
}});
|
||||
const item=outbox.enqueue({{repository:'o/r',title:'Possibly sent',body:'Check first'}});
|
||||
(async()=>{{
|
||||
await outbox.flush('timmy'); const attention=outbox.list()[0];
|
||||
await outbox.retry(item.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{attention,calls,remaining:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["attention"]["status"] == "attention"
|
||||
assert output["attention"]["deliveryState"] == "uncertain"
|
||||
assert output["calls"] == ["operation-1", "operation-2"]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'payload-' + (++sequence),
|
||||
}});
|
||||
const queued = outbox.enqueue({{
|
||||
repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09'
|
||||
}});
|
||||
const unchanged = outbox.update(queued.id, {{...queued}});
|
||||
const fields = [
|
||||
['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'],
|
||||
['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'],
|
||||
];
|
||||
const edits = [];
|
||||
for (const [field, value] of fields) {{
|
||||
const current = outbox.list()[0];
|
||||
edits.push(outbox.update(queued.id, {{...current, [field]:value}}));
|
||||
}}
|
||||
process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1"
|
||||
assert [item["operationId"] for item in output["edits"]] == [
|
||||
"payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7"
|
||||
]
|
||||
assert output["final"]["milestoneId"] == 4
|
||||
assert output["final"]["dueDate"] == "2026-08-10"
|
||||
|
||||
|
||||
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -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,150 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.later_store import LaterStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
class BusyStore:
|
||||
def apply(self, *_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is busy")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
|
||||
payload = main.LaterOperation(
|
||||
operation_id="retry-me",
|
||||
action="defer",
|
||||
item_id="issue:r:1:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.update_later_plan(payload)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.headers == {"Retry-After": "1"}
|
||||
|
||||
|
||||
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path)
|
||||
|
||||
deferred = store.apply(
|
||||
"Timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-10T09:00:00.000Z",
|
||||
)
|
||||
duplicate = store.apply(
|
||||
"timmy",
|
||||
"op-1",
|
||||
"defer",
|
||||
"issue:stackchain/dashboard:363:",
|
||||
wake_at="2026-08-11T09:00:00.000Z",
|
||||
)
|
||||
|
||||
assert duplicate == deferred == {
|
||||
"revision": 1,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||
},
|
||||
}
|
||||
assert LaterStore(path).get("timmy") == deferred
|
||||
assert store.get("alexander") == {"revision": 0, "records": {}}
|
||||
|
||||
assert store.apply(
|
||||
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
operations = [
|
||||
{"operation_id": "first", "action": "defer", "item_id": "issue:r:1:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
||||
{"operation_id": "second", "action": "restore", "item_id": "issue:r:1:"},
|
||||
]
|
||||
|
||||
result = store.apply_batch("timmy", operations)
|
||||
assert result == {
|
||||
"revision": 2,
|
||||
"records": {},
|
||||
"accepted_operation_ids": ["first", "second"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
replay = store.apply_batch("timmy", operations)
|
||||
assert replay["revision"] == 2
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
assert replay["duplicate_operation_ids"] == ["first", "second"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LATER_DB", str(tmp_path / "later.sqlite3"))
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
forbidden = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "defer",
|
||||
"item_id": "issue:stackchain/dashboard:363:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
},
|
||||
)
|
||||
changed = await client.patch(
|
||||
"/api/v1/later",
|
||||
json={
|
||||
"operations": [
|
||||
{"operation_id": "mobile-1", "action": "defer", "item_id": "issue:stackchain/dashboard:363:", "wake_at": "2026-08-10T09:00:00.000Z"},
|
||||
{"operation_id": "mobile-2", "action": "defer", "item_id": "issue:stackchain/dashboard:365:", "wake_at": "2026-08-11T09:00:00.000Z"},
|
||||
],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/later")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert changed.status_code == 200
|
||||
assert changed.json() == {
|
||||
"revision": 2,
|
||||
"records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
},
|
||||
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert fetched.json() == {"revision": 2, "records": {
|
||||
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z",
|
||||
"issue:stackchain/dashboard:365:": "2026-08-11T09:00:00.000Z",
|
||||
}}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,292 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(
|
||||
subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
)
|
||||
|
||||
|
||||
def test_two_tabs_cannot_erase_each_others_later_edits_or_double_drain():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const createCoordinator=require({json.dumps(str(COORDINATOR))});
|
||||
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const channels=[];const channelFactory=()=>{{const channel={{onmessage:null,postMessage:data=>channels.filter(x=>x!==channel).forEach(x=>x.onmessage?.({{data}})),close(){{}}}};channels.push(channel);return channel}};
|
||||
const held=new Set();const locks={{request:async(name,_options,work)=>{{if(held.has(name))return work(null);held.add(name);try{{return await work({{name}})}}finally{{held.delete(name)}}}}}};
|
||||
let sequence=0,releaseFirst;const delivered=[];
|
||||
const make=tab=>createLaterSync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,records:{{[operation.item_id]:operation.wake_at}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
||||
const first=make('first'),second=make('second');first.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('defer','issue:r:2:','2026-08-11T09:00:00.000Z');const competing=second.flush();releaseFirst();await Promise.all([draining,competing]);await first.flush();process.stdout.write(JSON.stringify({{delivered,pending:first.pending(),keys:[...values.keys()]}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert sorted(operation["operation_id"] for operation in result["delivered"]) == ["first-1", "second-2"]
|
||||
assert len(result["delivered"]) == 2
|
||||
assert result["pending"] == []
|
||||
assert not any(".operation." in key for key in result["keys"])
|
||||
|
||||
|
||||
def test_concurrent_later_records_for_one_item_deliver_only_the_newest_intent():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const prefix='stackchain.later-sync.v1.timmy.operation.';
|
||||
values.set(prefix+'old',JSON.stringify({{queued_at:100,operation:{{operation_id:'old',action:'defer',item_id:'issue:r:2:',wake_at:'2026-08-10T09:00:00.000Z'}}}}));
|
||||
values.set(prefix+'new',JSON.stringify({{queued_at:101,operation:{{operation_id:'new',action:'restore',item_id:'issue:r:2:',wake_at:null}}}}));
|
||||
const delivered=[];const sync=createLaterSync({{storage,getLogin:()=> 'timmy',fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,records:{{}}}};delivered.push(...JSON.parse(options.body).operations);return {{revision:1,records:{{}}}}}},onRemoteRecords:()=>{{}},onStatus:()=>{{}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,pending:sync.pending(),keys:[...values.keys()]}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert [operation["operation_id"] for operation in result["delivered"]] == ["new"]
|
||||
assert result["pending"] == []
|
||||
assert not any(".operation." in key for key in result["keys"])
|
||||
|
||||
|
||||
def test_offline_deferral_replays_once_and_adopts_server_records():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values = new Map();
|
||||
const requests = [];
|
||||
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
|
||||
const sync = createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
|
||||
fetchJson:async (url,options={{}})=>{{
|
||||
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
|
||||
if (!options.method) return remote;
|
||||
const operation=JSON.parse(options.body).operations[0];
|
||||
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
|
||||
return remote;
|
||||
}},
|
||||
onRemoteRecords:records=>{{globalThis.records=records}},
|
||||
onStatus:status=>{{globalThis.status=status}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
|
||||
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
|
||||
}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"api/v1/later",
|
||||
"api/v1/later",
|
||||
]
|
||||
assert result["requests"][0]["body"] == {"operations": [{
|
||||
"operation_id": "offline-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||
}]}
|
||||
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_pending_later_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:2,records:{{}},accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:1:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result=run_node(script)
|
||||
assert len(result["requests"]) == 1
|
||||
assert [item["operation_id"] for item in result["requests"][0]["operations"]] == ["op-1", "op-2"]
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); let sequence=0;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
sync.enqueue('restore','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"pending": [
|
||||
{
|
||||
"operation_id": "op-2",
|
||||
"action": "restore",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": None,
|
||||
}
|
||||
],
|
||||
"status": "retrying",
|
||||
}
|
||||
|
||||
|
||||
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
|
||||
script = f"""
|
||||
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
|
||||
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{
|
||||
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
|
||||
const scheduledBeforeSwitch=timers.length;
|
||||
login='alexander'; await timers[0].callback();
|
||||
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
|
||||
}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"scheduledBeforeSwitch": 1,
|
||||
"timers": 1,
|
||||
"requests": ["PATCH"],
|
||||
"pendingForAlexander": [],
|
||||
}
|
||||
|
||||
|
||||
def test_change_queued_during_delivery_is_drained_before_flush_settles():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return {{revision:0,records:{{}}}};
|
||||
const operation=JSON.parse(options.body).operations[0]; actions.push(operation.action);
|
||||
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
|
||||
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
|
||||
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
|
||||
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
|
||||
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actions": ["defer", "restore"],
|
||||
"pending": [],
|
||||
"records": {},
|
||||
}
|
||||
|
||||
|
||||
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const listeners=[];
|
||||
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
|
||||
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
|
||||
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
|
||||
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
|
||||
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
|
||||
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
|
||||
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
|
||||
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||
|
||||
|
||||
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
|
||||
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
|
||||
const first=sync.migrate(records),second=sync.migrate(records);
|
||||
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
|
||||
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"requests": ["PATCH"],
|
||||
"pending": [],
|
||||
}
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-08T12:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/api',number:17}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
|
||||
onExpire:ids=>expired.push(ids),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-08T16:00:00Z'));
|
||||
work.restore(item);
|
||||
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
|
||||
const remote=work.partition([item]);
|
||||
clock=new Date('2026-08-09T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["changes"] == [
|
||||
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
|
||||
["restore", "issue:stackchain/api:17:", None],
|
||||
]
|
||||
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
|
||||
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/later-sync.js"></script>' in html
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
assert "Later saved to account." in html
|
||||
|
||||
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -21,6 +21,9 @@ def reset_live_snapshot_task():
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
yield
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
@@ -35,6 +38,9 @@ def reset_live_snapshot_task():
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
main._live_section_revisions = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
|
||||
|
||||
def payload(response):
|
||||
@@ -84,6 +90,43 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
|
||||
assert result["sections"] == {
|
||||
"context": "fresh", "events": "fresh", "notifications": "fresh"
|
||||
}
|
||||
assert result["revisions"] == {
|
||||
"context": 1, "events": 1, "notifications": 1
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def empty():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
return [{"type": "push"}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", empty)
|
||||
monkeypatch.setattr(main, "issues", empty)
|
||||
monkeypatch.setattr(main, "pull_requests", empty)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", empty)
|
||||
|
||||
first = payload(await main.live_snapshot())
|
||||
second = payload(await main.live_snapshot(
|
||||
context_revision=first["revisions"]["context"],
|
||||
events_revision=first["revisions"]["events"],
|
||||
notifications_revision=first["revisions"]["notifications"],
|
||||
))
|
||||
|
||||
assert "context" not in second
|
||||
assert "events" not in second
|
||||
assert "notifications" not in second
|
||||
assert "notification_pagination" not in second
|
||||
assert second["sections"] == first["sections"]
|
||||
assert second["revisions"] == first["revisions"]
|
||||
assert "freshness" in second
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -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-v54" 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-v54" 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,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
|
||||
+484
-10
@@ -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,94 @@ PULL_SHEET = Path(__file__).parents[1] / "frontend" / "pull-sheet.js"
|
||||
CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
|
||||
|
||||
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
let claims = 0;
|
||||
let finishClaim;
|
||||
const states = [];
|
||||
const controller = createUpdateOwnership({{
|
||||
claim: () => {{ claims += 1; return new Promise(resolve => {{ finishClaim = resolve; }}); }},
|
||||
addToday: () => 'added',
|
||||
onClaimed: item => states.push({{status:'reconciled', item}}),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
controller.open({{
|
||||
repository:'stackchain/api', title:'Retry deploy',
|
||||
issue:{{number:7, assignees:[], claimable:true}},
|
||||
}}, {{notification_id:42, updated_at:'2026-08-08T12:00:00Z'}});
|
||||
const first = controller.act();
|
||||
const second = controller.act();
|
||||
if (first !== second || claims !== 1) throw new Error('claim was not single-flight');
|
||||
finishClaim({{number:7, title:'Retry deploy', assignees:['timmy'], state:'open'}});
|
||||
(async () => {{
|
||||
await first;
|
||||
const added = await controller.act();
|
||||
process.stdout.write(JSON.stringify({{claims, added, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["claims"] == 1
|
||||
assert output["added"] == "added"
|
||||
assert output["states"][0] == {"action": "claim", "busy": False, "message": ""}
|
||||
assert {"action": "claim", "busy": True, "message": "Assigning…"} in output["states"]
|
||||
reconciled = next(state for state in output["states"] if state.get("status") == "reconciled")
|
||||
assert reconciled["item"]["notification_id"] == 42
|
||||
assert reconciled["item"]["repository"] == "stackchain/api"
|
||||
assert reconciled["item"]["kind"] == "issue"
|
||||
assert output["states"][-1] == {
|
||||
"action": "today", "busy": False, "message": "Added to Today."
|
||||
}
|
||||
|
||||
|
||||
def test_update_ownership_conflict_removes_stale_action_without_reconciling():
|
||||
script = f"""
|
||||
const createUpdateOwnership = require({json.dumps(str(UPDATE_OWNERSHIP))});
|
||||
const states = [];
|
||||
let reconciled = 0;
|
||||
const conflict = new Error('server wording'); conflict.status = 409;
|
||||
const controller = createUpdateOwnership({{
|
||||
claim: () => Promise.reject(conflict), addToday: () => 'added',
|
||||
onClaimed: () => {{ reconciled += 1; }}, onState: state => states.push(state),
|
||||
}});
|
||||
controller.open({{repository:'stackchain/api', issue:{{number:7, claimable:true}}}}, {{notification_id:42}});
|
||||
(async () => {{
|
||||
const result = await controller.act();
|
||||
process.stdout.write(JSON.stringify({{result, reconciled, states}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
assert output["result"] == "conflict"
|
||||
assert output["reconciled"] == 0
|
||||
assert output["states"][-1] == {
|
||||
"action": "hidden",
|
||||
"busy": False,
|
||||
"message": "Someone else claimed or closed this issue. The update is still unread.",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_phone_safe_ownership_to_my_work_and_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-ownership.js"></script>' in html
|
||||
assert 'id="update-ownership-action"' in html
|
||||
assert 'hidden aria-describedby="update-sheet-status"' in html
|
||||
assert 'const updateOwnership = createUpdateOwnership({' in html
|
||||
assert 'updateOwnership.open(detail, selectedUpdate)' in html
|
||||
assert "qs('#update-ownership-action').addEventListener('click'" in html
|
||||
assert "lastContextSnapshot.issues" in html
|
||||
assert "todayWork.add(item)" in html
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
|
||||
|
||||
def test_work_routes_round_trip_all_sheet_kinds_and_reject_unsafe_fragments():
|
||||
@@ -470,6 +559,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
|
||||
assert result.stdout == "Issue release was not confirmed."
|
||||
|
||||
|
||||
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let calls = [];
|
||||
let finish;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: (url, options) => {{
|
||||
calls.push({{url, options}});
|
||||
return new Promise(resolve => {{ finish = resolve; }});
|
||||
}},
|
||||
storage: null,
|
||||
}});
|
||||
const item = {{repository:'stackchain/api', number:17}};
|
||||
const first = controller.handoff(item, 'alex', 'timmy');
|
||||
const duplicate = controller.handoff(item, 'alex', 'timmy');
|
||||
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, same: first === duplicate, results
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["same"] is True
|
||||
assert len(output["calls"]) == 1
|
||||
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
|
||||
assert output["calls"][0]["options"]["method"] == "PATCH"
|
||||
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
|
||||
assert output["results"][0]["assignees"] == ["alex"]
|
||||
|
||||
|
||||
def test_issue_handoff_candidates_use_the_assigned_issue_route():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
let call;
|
||||
const controller = createIssueSheet({{
|
||||
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
|
||||
storage: null,
|
||||
}});
|
||||
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
|
||||
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
|
||||
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
|
||||
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
|
||||
|
||||
|
||||
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
|
||||
script = f"""
|
||||
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
|
||||
@@ -576,6 +717,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="issue-handoff-recipient"' in html
|
||||
assert 'id="load-issue-handoff"' in html
|
||||
assert 'id="confirm-issue-handoff"' in html
|
||||
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
|
||||
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
|
||||
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
|
||||
assert "issueController.handoff(selectedIssue, recipient" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
|
||||
html = await dashboard()
|
||||
@@ -599,7 +753,7 @@ async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
|
||||
assert 'id="resume-issue-draft"' in html
|
||||
assert 'id="shared-content-conflict"' in html
|
||||
assert '.shared-content-actions button { min-height:44px;' in html
|
||||
assert "history.replaceState({}, '', cleanUrl)" in html
|
||||
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
||||
assert "navigator.serviceWorker.register('service-worker.js')" in html
|
||||
|
||||
|
||||
@@ -676,7 +830,7 @@ async def test_opening_issue_defers_planning_requests_until_disclosure_expands()
|
||||
|
||||
assert 'loadIssueLabelEditor(' not in open_handler
|
||||
assert 'loadIssueMilestoneEditor(' not in open_handler
|
||||
assert "if (qs('#issue-planning').open) loadIssuePlanning();" in open_handler
|
||||
assert "if (qs('#issue-planning').open && !offlineDetail) loadIssuePlanning();" in open_handler
|
||||
assert "qs('#issue-planning').addEventListener('toggle'" in html
|
||||
assert 'planningLoader.open(selectedIssue)' in html
|
||||
assert 'id="retry-issue-planning"' in html
|
||||
@@ -760,7 +914,7 @@ process.stdout.write(JSON.stringify({{
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"deferred": True,
|
||||
"deferred": "deferred",
|
||||
"timmy": {
|
||||
"active": 0,
|
||||
"later": [{
|
||||
@@ -773,6 +927,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))});
|
||||
@@ -899,7 +1080,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']),
|
||||
@@ -935,6 +1116,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()
|
||||
@@ -958,16 +1165,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():
|
||||
@@ -1461,7 +1691,7 @@ async def test_mobile_find_work_cards_preview_escaped_context_without_extra_requ
|
||||
assert 'aria-controls="' in html
|
||||
assert "const detailId = 'find-work-detail-' + index" in html
|
||||
assert 'class="find-work-detail"' in html
|
||||
assert "escapeHtml(item.body || 'No description provided.')" in html
|
||||
assert "renderMarkdown(item.body || 'No description provided.')" in html
|
||||
assert '>Open in Gitea</a>' in html
|
||||
assert '.find-work-card a, .find-work-more { min-height:44px;' in html
|
||||
assert '.find-work-detail { min-width:0;' in html
|
||||
@@ -1736,6 +1966,113 @@ Promise.all([capture.loadMilestones('stackchain/api'), capture.submit(capture.lo
|
||||
assert output["stored"]["number"] == 221
|
||||
|
||||
|
||||
def test_issue_capture_finds_only_open_issue_duplicates_in_the_selected_repository():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const calls = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: url => {{
|
||||
calls.push(url);
|
||||
return Promise.resolve({{items:[
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:7, title:'Investigate checkout latency'}},
|
||||
{{kind:'pull', state:'open', repository:'stackchain/api', number:8, title:'Investigate checkout latency'}},
|
||||
{{kind:'issue', state:'closed', repository:'stackchain/api', number:9, title:'Investigate checkout latency'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/web', number:10, title:'Investigate checkout latency'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:11, title:'Checkout latency alert'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:12, title:'Checkout latency follow-up'}},
|
||||
{{kind:'issue', state:'open', repository:'stackchain/api', number:13, title:'Fourth match is bounded'}},
|
||||
]}});
|
||||
}},
|
||||
}});
|
||||
capture.findDuplicates({{repository:'stackchain/api', title:' Investigate checkout latency '}}).then(state =>
|
||||
process.stdout.write(JSON.stringify({{calls, state}}))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == [
|
||||
"api/v1/search?q=Investigate%20checkout%20latency&limit=10"
|
||||
]
|
||||
assert output["state"]["status"] == "ready"
|
||||
assert [item["number"] for item in output["state"]["candidates"]] == [7, 11, 12]
|
||||
|
||||
|
||||
def test_issue_capture_ignores_stale_duplicate_results_and_resets_create_anyway_acknowledgement():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const pending = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: url => new Promise((resolve, reject) => pending.push({{url, resolve, reject}})),
|
||||
}});
|
||||
const firstDraft = {{repository:'stackchain/api', title:'Checkout latency'}};
|
||||
const latestDraft = {{repository:'stackchain/api', title:'Checkout latency alert'}};
|
||||
const first = capture.findDuplicates(firstDraft);
|
||||
const latest = capture.findDuplicates(latestDraft);
|
||||
pending[1].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:22,title:'Checkout latency alert'}}]}});
|
||||
latest.then(latestState => {{
|
||||
const blocked = capture.needsDuplicateAcknowledgement(latestDraft);
|
||||
capture.acknowledgeDuplicates(latestDraft);
|
||||
const allowed = capture.needsDuplicateAcknowledgement(latestDraft);
|
||||
const changedDraft = {{...latestDraft,title:'Checkout latency alert today'}};
|
||||
const changedSearch = capture.findDuplicates(changedDraft);
|
||||
pending[2].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:23,title:'Checkout latency alert today'}}]}});
|
||||
changedSearch.then(() => {{
|
||||
const changed = capture.needsDuplicateAcknowledgement(changedDraft);
|
||||
pending[0].resolve({{items:[{{kind:'issue',state:'open',repository:'stackchain/api',number:21,title:'Old result'}}]}});
|
||||
first.then(staleState => {{
|
||||
const failure = capture.findDuplicates({{repository:'stackchain/api',title:'Network failure'}});
|
||||
pending[3].reject(new Error('offline'));
|
||||
failure.then(failedState => process.stdout.write(JSON.stringify({{
|
||||
latestState, staleState, blocked, allowed, changed, failedState,
|
||||
failureBlocks:capture.needsDuplicateAcknowledgement({{repository:'stackchain/api',title:'Network failure'}}),
|
||||
}})));
|
||||
}});
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["latestState"]["status"] == "ready"
|
||||
assert output["staleState"]["status"] == "stale"
|
||||
assert output["blocked"] is True
|
||||
assert output["allowed"] is False
|
||||
assert output["changed"] is True
|
||||
assert output["failedState"]["status"] == "failed"
|
||||
assert output["failureBlocks"] is False
|
||||
|
||||
|
||||
def test_issue_capture_keeps_partial_duplicate_search_advisory():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const draft = {{repository:'stackchain/api', title:'Checkout latency'}};
|
||||
const capture = createIssueCapture({{
|
||||
storage: {{getItem:()=>null, setItem:()=>{{}}, removeItem:()=>{{}}}},
|
||||
fetchJson: () => Promise.resolve({{partial:true, items:[
|
||||
{{kind:'issue',state:'open',repository:'stackchain/api',number:24,title:'Checkout latency'}},
|
||||
]}}),
|
||||
}});
|
||||
capture.findDuplicates(draft).then(state => process.stdout.write(JSON.stringify({{
|
||||
state, blocked:capture.needsDuplicateAcknowledgement(draft),
|
||||
}})));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["state"]["partial"] is True
|
||||
assert [item["number"] for item in output["state"]["candidates"]] == [24]
|
||||
assert output["blocked"] is False
|
||||
|
||||
|
||||
def test_unread_updates_enrich_matching_work_and_keep_unassigned_mentions_actionable():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
@@ -2153,6 +2490,98 @@ reader.open(firstItem).then(async () => {{
|
||||
assert output["states"] == [[41], [90]]
|
||||
|
||||
|
||||
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const createPager = require({json.dumps(str(CONVERSATION))});
|
||||
const item = {{kind:'update', notification_id:42, has_update:true}};
|
||||
const saved = {{
|
||||
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
|
||||
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
|
||||
}};
|
||||
let detailLoads = 0;
|
||||
let conversationLoads = 0;
|
||||
let markReads = 0;
|
||||
const details = [];
|
||||
const conversations = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
|
||||
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
|
||||
markRead: async () => {{ markReads += 1; }}, createPager,
|
||||
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
|
||||
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
|
||||
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
|
||||
}});
|
||||
(async () => {{
|
||||
const opened = await reader.open(item, saved);
|
||||
const older = await reader.loadOlder();
|
||||
const marked = await reader.markReadAndNext([item]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"older": False,
|
||||
"marked": False,
|
||||
"detailLoads": 0,
|
||||
"conversationLoads": 0,
|
||||
"markReads": 0,
|
||||
"details": ["Saved update"],
|
||||
"conversations": [[41]],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_reader_queues_saved_update_read_offline_and_opens_next_saved_update():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{kind:'update', notification_id:42, has_update:true, title:'First'}},
|
||||
{{kind:'update', notification_id:43, has_update:true, title:'Second'}},
|
||||
];
|
||||
const saved = new Map([
|
||||
[42, {{id:42, title:'Saved first', conversation:{{comments:[],page:1,total:0}}}}],
|
||||
[43, {{id:43, title:'Saved second', conversation:{{comments:[],page:1,total:0}}}}],
|
||||
]);
|
||||
const events = [];
|
||||
const reader = build.createNotificationReader({{
|
||||
load: async () => {{ throw new Error('network must not run'); }},
|
||||
markRead: async () => {{ throw new Error('network must not run'); }},
|
||||
queueRead: async id => events.push(['queued', id]),
|
||||
loadSaved: item => saved.get(item.notification_id),
|
||||
onOpen: item => events.push(['open', item.notification_id]),
|
||||
onDetail: detail => events.push(['detail', detail.id]),
|
||||
onItems: next => events.push(['items', next.map(item => item.notification_id)]),
|
||||
onStatus: status => events.push(['status', status]), onClose: () => events.push(['close']),
|
||||
}});
|
||||
(async () => {{
|
||||
await reader.open(items[0], saved.get(42));
|
||||
events.length = 0;
|
||||
const result = await reader.markReadAndNext(items);
|
||||
process.stdout.write(JSON.stringify({{result, events}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["result"]["next"]["notification_id"] == 43
|
||||
assert output["events"] == [
|
||||
["status", "Queueing update read…"],
|
||||
["queued", 42],
|
||||
["items", [43]],
|
||||
["open", 43],
|
||||
["status", "Loading update…"],
|
||||
["detail", 43],
|
||||
["status", "Update ready."],
|
||||
]
|
||||
|
||||
|
||||
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
|
||||
script = f"""
|
||||
const build = require({json.dumps(str(MY_WORK))});
|
||||
@@ -2528,6 +2957,31 @@ Promise.all([first, duplicate]).then(async comments => {{
|
||||
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
|
||||
|
||||
|
||||
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
const controller = createPullSheet({{
|
||||
storage: null,
|
||||
fetchJson: () => Promise.resolve({{
|
||||
number: 7,
|
||||
merged: false,
|
||||
state: 'unknown',
|
||||
confirmation_pending: true,
|
||||
error: 'Merge confirmation is pending. Check its status before retrying.',
|
||||
}}),
|
||||
}});
|
||||
controller.merge({{repository:'stackchain/api', number:7}}, 'abc123')
|
||||
.then(() => process.stdout.write(JSON.stringify({{resolved:true}})))
|
||||
.catch(error => process.stdout.write(JSON.stringify({{resolved:false, message:error.message}})));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"resolved": False,
|
||||
"message": "Merge confirmation is pending. Check its status before retrying.",
|
||||
}
|
||||
|
||||
|
||||
def test_pull_sheet_enables_merge_only_for_current_safe_state():
|
||||
script = f"""
|
||||
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
|
||||
@@ -2801,6 +3255,25 @@ async def test_mobile_my_work_captures_new_issue_in_accessible_draft_safe_sheet(
|
||||
assert '.create-issue-label-option' in html and 'min-height:44px' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_warns_before_queuing_a_possible_duplicate():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-duplicates"' in html
|
||||
assert 'id="create-issue-duplicate-list"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'id="create-issue-anyway"' in html and 'Create anyway' in html
|
||||
assert "issueCapture.findDuplicates(draft)" in html
|
||||
assert "issueCapture.needsDuplicateAcknowledgement(captureDraft)" in html
|
||||
assert "issueCapture.acknowledgeDuplicates(captureDraft)" in html
|
||||
assert html.index("issueCapture.needsDuplicateAcknowledgement(captureDraft)") < html.index(
|
||||
"issueOutbox.enqueueDurably(captureDraft)"
|
||||
)
|
||||
assert ".create-issue-duplicate-card" in html
|
||||
assert ".create-issue-duplicate-card a" in html and "min-height:44px" in html
|
||||
assert "overflow-wrap:anywhere" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
@@ -2964,13 +3437,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert 'data-work-count="attention"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'data-work-count="update"' in html
|
||||
assert 'data-work-count="later"' in html
|
||||
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
|
||||
assert 'data-work-count="draft"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
||||
@@ -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
|
||||
+164
-2
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
|
||||
@@ -87,6 +87,115 @@ process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load(
|
||||
assert result == {"enabled": False, "loaded": None}
|
||||
|
||||
|
||||
def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2});
|
||||
store.setEnabled(true);
|
||||
const issue = {kind:'issue', repository:'stackchain/dashboard', number:8};
|
||||
store.saveDetail('timmy', issue, {
|
||||
title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'],
|
||||
url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}],
|
||||
conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z',
|
||||
token:'comment-secret'}], page:1, older_page:null, total:1},
|
||||
});
|
||||
store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'});
|
||||
store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'});
|
||||
const raw = [...values.values()].join(' ');
|
||||
process.stdout.write(JSON.stringify({
|
||||
evicted:store.loadDetail('timmy', issue),
|
||||
loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}),
|
||||
wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}),
|
||||
raw,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["evicted"] is None
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["title"] == "PR 9"
|
||||
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
|
||||
assert "secret" not in result["raw"]
|
||||
assert "private diff" not in result["raw"]
|
||||
|
||||
|
||||
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const update = {kind:'update', notification_id:42};
|
||||
store.saveDetail('timmy', update, {
|
||||
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
|
||||
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
|
||||
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
|
||||
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
|
||||
conversation:{
|
||||
comments:Array.from({length:25}, (_, index) => ({
|
||||
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
|
||||
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
|
||||
})),
|
||||
page:1, older_page:2, total:40,
|
||||
},
|
||||
});
|
||||
const raw = [...values.values()].join(' ');
|
||||
process.stdout.write(JSON.stringify({
|
||||
loaded:store.loadDetail('timmy', update),
|
||||
wrongUser:store.loadDetail('alexander', update),
|
||||
raw,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["title"] == "Deployment blocked"
|
||||
assert result["loaded"]["subject_body"] == "Safe subject context"
|
||||
assert result["loaded"]["subject_type"] == "Issue"
|
||||
assert len(result["loaded"]["conversation"]["comments"]) == 20
|
||||
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
|
||||
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
|
||||
assert "secret" not in result["raw"]
|
||||
assert "private diff" not in result["raw"]
|
||||
|
||||
|
||||
def test_requested_review_cache_is_sha_scoped_allowlisted_and_bounded():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
store.setEnabled(true);
|
||||
const review = {kind:'pull', repository:'stackchain/dashboard', number:12, is_review:true};
|
||||
store.saveDetail('timmy', review, {
|
||||
title:'Review offline work', body:'Review description', author:'alexander', head_sha:'abc123',
|
||||
ci_state:'success', token:'top-secret', source_updated_at:'2026-08-07T11:00:00Z',
|
||||
files:Array.from({length:55}, (_, fileIndex) => ({
|
||||
filename:'src/file-' + fileIndex + '.js', status:'modified', additions:2, deletions:1,
|
||||
diff_available:true, diff_binary:false, diff_truncated:false, patch:'must-not-persist',
|
||||
diff_lines:Array.from({length:405}, (_, lineIndex) => '+' + fileIndex + ':' + lineIndex),
|
||||
})),
|
||||
reviews:Array.from({length:25}, (_, index) => ({
|
||||
id:index + 1, state:'COMMENT', body:'Review ' + (index + 1),
|
||||
submitted_at:'2026-08-07T10:00:00Z', user:{login:'reviewer-' + index, email:'private@example.test'},
|
||||
token:'review-secret',
|
||||
})),
|
||||
});
|
||||
const loaded = store.loadDetail('timmy', review);
|
||||
process.stdout.write(JSON.stringify({
|
||||
loaded,
|
||||
wrongUser:store.loadDetail('alexander', review),
|
||||
raw:[...values.values()].join(' '),
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result["wrongUser"] is None
|
||||
assert result["loaded"]["head_sha"] == "abc123"
|
||||
assert result["loaded"]["ci_state"] == "success"
|
||||
assert len(result["loaded"]["files"]) == 50
|
||||
assert len(result["loaded"]["files"][0]["diff_lines"]) == 400
|
||||
assert result["loaded"]["files"][0]["filename"] == "src/file-0.js"
|
||||
assert len(result["loaded"]["reviews"]) == 20
|
||||
assert result["loaded"]["reviews"][0]["body"] == "Review 6"
|
||||
assert result["loaded"]["reviews"][0]["user"] == {"login": "reviewer-5"}
|
||||
assert "top-secret" not in result["raw"]
|
||||
assert "must-not-persist" not in result["raw"]
|
||||
assert "private@example.test" not in result["raw"]
|
||||
assert "review-secret" not in result["raw"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
|
||||
html = await dashboard()
|
||||
@@ -112,4 +221,57 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
|
||||
assert "Outage · saved " in html
|
||||
assert "Server unavailable · showing private My Work saved " in html
|
||||
assert "Live details and actions will return automatically." in html
|
||||
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
|
||||
assert "function renderLiveSnapshot(snapshot, changedSections" in html
|
||||
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_today_details_open_offline_without_enabling_server_state_actions():
|
||||
html = await dashboard()
|
||||
|
||||
assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html
|
||||
assert "Details not saved—reconnect to open this item." in html
|
||||
assert "openIssueSheet(item, trigger, savedDetail)" in html
|
||||
assert "openPullSheet(item, trigger, savedDetail)" in html
|
||||
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html
|
||||
assert "todayWork.contains(item)" in html
|
||||
assert "Offline copy · saved " in html
|
||||
assert "setOfflineDetailControls('issue')" in html
|
||||
assert "setOfflineDetailControls('pull')" in html
|
||||
assert "qs('#issue-planning').inert = true;" in html
|
||||
assert "qs('#issue-handoff').inert = true;" in html
|
||||
assert "qs('#pull-review').inert = true;" in html
|
||||
assert "qs('#issue-planning').inert = false;" in html
|
||||
assert "qs('#pull-review').inert = false;" in html
|
||||
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_controls():
|
||||
html = await dashboard()
|
||||
|
||||
assert "notificationReader.open(item, savedDetail)" in html
|
||||
assert "openRoutedWork(item, button);" in html
|
||||
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
|
||||
assert "Offline update · saved " in html
|
||||
assert "setOfflineUpdateControls(true)" in html
|
||||
assert "qs('#mark-update-read-next').disabled = false;" in html
|
||||
assert "offline ? 'Queue read & next' : 'Mark read & next'" in html
|
||||
assert "qs('#update-ownership-action').disabled = offline;" in html
|
||||
assert "qs('#load-older-update-comments').disabled = offline;" in html
|
||||
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
|
||||
assert "replies and read acknowledgements queue for sync" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
|
||||
html = await dashboard()
|
||||
|
||||
assert "item.is_review ? reviewController.load(item)" in html
|
||||
assert "openReviewSheet(item, trigger, savedDetail)" in html
|
||||
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
|
||||
assert "cachedDetail || await reviewController.load(selectedReview)" in html
|
||||
assert "Offline review · saved " in html
|
||||
assert "Reconnect to validate & submit" in html
|
||||
assert "qs('#submit-review').disabled = offlineReview;" in html
|
||||
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
|
||||
@@ -4,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,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
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -247,6 +249,108 @@ async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
|
||||
assert calls == [("stackchain/api", 7, "abc123")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypatch):
|
||||
calls = []
|
||||
merged = False
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(repository, number, expected_head_sha):
|
||||
nonlocal merged
|
||||
calls.append(("merge", repository, number, expected_head_sha))
|
||||
merged = True
|
||||
await asyncio.sleep(0.05)
|
||||
return {"number": number, "merged": True, "state": "closed"}
|
||||
|
||||
async def confirm(repository, number, expected_head_sha):
|
||||
calls.append(("confirm", repository, number, expected_head_sha))
|
||||
return merged
|
||||
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
|
||||
assert calls == [
|
||||
("merge", "stackchain/api", 7, "abc123"),
|
||||
("confirm", "stackchain/api", 7, "abc123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_merge_confirmation_requires_merged_expected_head():
|
||||
requests = []
|
||||
|
||||
async def handler(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"state": "closed",
|
||||
"merged": True,
|
||||
"head": {"sha": "abc123"},
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
confirmed = await gitea_proxy.is_pull_merged_at_head(
|
||||
"stackchain/api", 7, "abc123"
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert confirmed is True
|
||||
assert requests == [("GET", "/api/v1/repos/stackchain/api/pulls/7")]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_reports_unresolved_confirmation_without_false_state(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def assigned(repository, number):
|
||||
return True
|
||||
|
||||
async def merge(repository, number, expected_head_sha):
|
||||
calls.append(("merge", repository, number, expected_head_sha))
|
||||
raise httpx.ReadTimeout("upstream response was lost")
|
||||
|
||||
async def confirm(repository, number, expected_head_sha):
|
||||
calls.append(("confirm", repository, number, expected_head_sha))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.json() == {
|
||||
"number": 7,
|
||||
"merged": False,
|
||||
"state": "unknown",
|
||||
"confirmation_pending": True,
|
||||
"error": "Merge confirmation is pending. Check its status before retrying.",
|
||||
}
|
||||
assert calls == [
|
||||
("merge", "stackchain/api", 7, "abc123"),
|
||||
("confirm", "stackchain/api", 7, "abc123"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
|
||||
async def assigned(repository, number):
|
||||
|
||||
@@ -32,3 +32,28 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
|
||||
assert "`/readyz`" in text
|
||||
assert "does not contact Gitea" in text
|
||||
assert "HTTP 503" in text
|
||||
|
||||
|
||||
def test_readme_documents_exact_later_return_times():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "Choose date & time" in text
|
||||
assert "valid future local date and time" in text
|
||||
assert "exact instant" in text
|
||||
|
||||
|
||||
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "opened Today issue or pull request" in text
|
||||
assert "newest 20 comments" in text
|
||||
assert "comments can enter the account-bound durable outbox" in text
|
||||
assert "planning, assignment, review, merge, and close controls remain disabled" in text
|
||||
|
||||
|
||||
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
assert "previously opened unread update" in text
|
||||
assert "replies enter the account-bound durable outbox" in text
|
||||
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
|
||||
@@ -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
|
||||
@@ -1,5 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,12 +16,85 @@ from src.release_engine import (
|
||||
Issue,
|
||||
ReleaseEngine,
|
||||
RunState,
|
||||
ShellRunner,
|
||||
StateStore,
|
||||
_parse_args,
|
||||
branch_name,
|
||||
select_issue,
|
||||
)
|
||||
|
||||
|
||||
def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
|
||||
output = tmp_path / "argument.txt"
|
||||
injected = tmp_path / "injected.txt"
|
||||
payload = f"release title; touch {injected}"
|
||||
|
||||
result = ShellRunner().run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])",
|
||||
str(output),
|
||||
payload,
|
||||
],
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert output.read_text() == payload
|
||||
assert not injected.exists()
|
||||
|
||||
|
||||
def test_shell_runner_uses_only_the_explicit_environment(tmp_path, monkeypatch):
|
||||
output = tmp_path / "environment.json"
|
||||
monkeypatch.setenv("GITEA_TOKEN", "write-capable-secret")
|
||||
monkeypatch.setenv("UNRELATED_SECRET", "also-secret")
|
||||
|
||||
result = ShellRunner().run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import json, os, sys; json.dump(dict(os.environ), open(sys.argv[1], 'w'))",
|
||||
str(output),
|
||||
],
|
||||
tmp_path,
|
||||
{"PATH": os.environ["PATH"], "EXPECTED_VALUE": "available"},
|
||||
)
|
||||
|
||||
environment = json.loads(output.read_text())
|
||||
assert result.returncode == 0
|
||||
assert environment["EXPECTED_VALUE"] == "available"
|
||||
assert "GITEA_TOKEN" not in environment
|
||||
assert "UNRELATED_SECRET" not in environment
|
||||
|
||||
|
||||
def test_shell_runner_times_out_and_terminates_spawned_children(tmp_path):
|
||||
marker = tmp_path / "child-survived.txt"
|
||||
child = (
|
||||
"import time; from pathlib import Path; "
|
||||
f"time.sleep(0.3); Path({str(marker)!r}).write_text('survived')"
|
||||
)
|
||||
parent = (
|
||||
"import subprocess, sys, time; "
|
||||
f"subprocess.Popen([sys.executable, '-c', {child!r}]); "
|
||||
"time.sleep(10)"
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
result = ShellRunner().run(
|
||||
[sys.executable, "-c", parent],
|
||||
tmp_path,
|
||||
timeout_seconds=0.05,
|
||||
termination_grace_seconds=0.05,
|
||||
)
|
||||
elapsed = time.monotonic() - started
|
||||
time.sleep(0.35)
|
||||
|
||||
assert result.timed_out is True
|
||||
assert elapsed < 0.5
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
def test_stale_token_error_explains_how_to_restore_cron_authentication():
|
||||
class UnauthorizedHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
@@ -70,9 +147,13 @@ class FakeRunner:
|
||||
def __init__(self, results):
|
||||
self.results = list(results)
|
||||
self.commands = []
|
||||
self.environments = []
|
||||
self.options = []
|
||||
|
||||
def run(self, command, cwd, env=None):
|
||||
def run(self, command, cwd, env=None, **options):
|
||||
self.commands.append(command)
|
||||
self.environments.append(env)
|
||||
self.options.append(options)
|
||||
return self.results.pop(0)
|
||||
|
||||
|
||||
@@ -91,7 +172,7 @@ def config(tmp_path):
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command="agent --issue {issue_number}",
|
||||
agent_command="agent --issue-from-env",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
@@ -121,6 +202,74 @@ def test_claim_verification_blocks_execution(tmp_path):
|
||||
assert runner.commands == []
|
||||
|
||||
|
||||
def test_empty_agent_command_fails_before_claim(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = EngineConfig(
|
||||
repo="stackchain/stackchain-dashboard",
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command=" ",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="invalid agent command: command is empty"):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("agent_timeout_seconds", 0),
|
||||
("test_timeout_seconds", -1),
|
||||
("termination_grace_seconds", 0),
|
||||
],
|
||||
)
|
||||
def test_non_positive_command_deadlines_fail_before_claim(tmp_path, field, value):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = replace(config(tmp_path), **{field: value})
|
||||
|
||||
with pytest.raises(ValueError, match=field.replace("_", " ")):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["GITEA_TOKEN", "RELEASE_REPO", "bad-name", "1START"])
|
||||
def test_invalid_agent_environment_allowlist_fails_before_claim(tmp_path, name):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = replace(config(tmp_path), agent_env_vars=(name,))
|
||||
|
||||
with pytest.raises(ValueError, match="invalid agent environment variable"):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
|
||||
|
||||
assert client.claimed == []
|
||||
|
||||
|
||||
def test_command_deadlines_are_configurable_from_cli(monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
"release-engine",
|
||||
"--repo", "stackchain/stackchain-dashboard",
|
||||
"--agent-timeout", "120",
|
||||
"--test-timeout", "45.5",
|
||||
"--termination-grace", "2",
|
||||
"--agent-env", "CODEX_HOME",
|
||||
"--agent-env", "HTTPS_PROXY",
|
||||
])
|
||||
|
||||
args = _parse_args()
|
||||
|
||||
assert args.agent_timeout == 120
|
||||
assert args.test_timeout == 45.5
|
||||
assert args.termination_grace == 2
|
||||
assert args.agent_env == ["CODEX_HOME", "HTTPS_PROXY"]
|
||||
|
||||
|
||||
def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
@@ -134,18 +283,110 @@ def test_agent_failure_blocks_tests_and_pr(tmp_path):
|
||||
engine.execute(issue_number=14)
|
||||
|
||||
assert runner.commands == [
|
||||
"git checkout -B timmy/14-engine",
|
||||
"agent --issue 14",
|
||||
["git", "checkout", "-B", "timmy/14-engine"],
|
||||
["agent", "--issue-from-env"],
|
||||
]
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_agent_timeout_records_terminal_state_and_blocks_tests(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(-9, "partial output", "stalled", timed_out=True),
|
||||
])
|
||||
cfg = config(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="coding agent timed out"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
state = StateStore(cfg.state_path).load()
|
||||
assert state is not None
|
||||
assert state.status == "agent_timed_out"
|
||||
assert "partial output" in state.evidence
|
||||
assert runner.commands == [
|
||||
["git", "checkout", "-B", "timmy/14-engine"],
|
||||
["agent", "--issue-from-env"],
|
||||
]
|
||||
assert runner.options[1] == {
|
||||
"timeout_seconds": cfg.agent_timeout_seconds,
|
||||
"termination_grace_seconds": cfg.termination_grace_seconds,
|
||||
}
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_issue_content_reaches_agent_only_through_environment(tmp_path):
|
||||
title = "$(touch /tmp/title-injected); `id`"
|
||||
body = "line one\n&& touch /tmp/body-injected > stolen"
|
||||
issue = Issue(14, title, body, "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(1, "", "agent stopped"),
|
||||
])
|
||||
cfg = EngineConfig(
|
||||
repo="stackchain/stackchain-dashboard",
|
||||
agent="timmy",
|
||||
repo_path=tmp_path,
|
||||
state_path=tmp_path / "state.json",
|
||||
agent_command="agent --work",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="coding agent failed"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
assert runner.commands == [
|
||||
["git", "checkout", "-B", "timmy/14-touch-tmp-title-injected-id"],
|
||||
["agent", "--work"],
|
||||
]
|
||||
assert runner.environments[1]["RELEASE_ISSUE_TITLE"] == title
|
||||
assert runner.environments[1]["RELEASE_ISSUE_BODY"] == body
|
||||
|
||||
|
||||
def test_release_stages_receive_least_privilege_environments(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GITEA_TOKEN", "write-capable-secret")
|
||||
monkeypatch.setenv("UNRELATED_SECRET", "also-secret")
|
||||
monkeypatch.setenv("CODEX_HOME", "/approved/codex")
|
||||
issue = Issue(14, "Engine MVP", "sensitive body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "implemented", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "8 passed", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
cfg = replace(config(tmp_path), agent_env_vars=("CODEX_HOME",))
|
||||
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
for environment in runner.environments:
|
||||
assert environment is not None
|
||||
assert "PATH" in environment
|
||||
assert "GITEA_TOKEN" not in environment
|
||||
assert "UNRELATED_SECRET" not in environment
|
||||
agent_environment = runner.environments[1]
|
||||
assert agent_environment["CODEX_HOME"] == "/approved/codex"
|
||||
assert agent_environment["RELEASE_ISSUE_NUMBER"] == "14"
|
||||
assert agent_environment["RELEASE_ISSUE_TITLE"] == issue.title
|
||||
assert agent_environment["RELEASE_ISSUE_BODY"] == issue.body
|
||||
assert agent_environment["RELEASE_REPO"] == cfg.repo
|
||||
assert agent_environment["RELEASE_BRANCH"] == "timmy/14-engine-mvp"
|
||||
assert not any(
|
||||
name.startswith("RELEASE_")
|
||||
for name in runner.environments[3]
|
||||
)
|
||||
|
||||
|
||||
def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "changed", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(1, "1 failed", ""),
|
||||
])
|
||||
engine = ReleaseEngine(config(tmp_path), client, runner)
|
||||
@@ -153,7 +394,33 @@ def test_failed_tests_block_push_and_pr(tmp_path):
|
||||
with pytest.raises(RuntimeError, match="tests failed"):
|
||||
engine.execute(issue_number=14)
|
||||
|
||||
assert "git push" not in " ".join(runner.commands)
|
||||
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_test_timeout_records_terminal_state_and_blocks_push(tmp_path):
|
||||
issue = Issue(14, "Engine", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "implemented", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(-15, "3 passed before stall", "", timed_out=True),
|
||||
])
|
||||
cfg = config(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="tests timed out"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
|
||||
|
||||
state = StateStore(cfg.state_path).load()
|
||||
assert state is not None
|
||||
assert state.status == "tests_timed_out"
|
||||
assert "3 passed before stall" in state.evidence
|
||||
assert runner.options[3] == {
|
||||
"timeout_seconds": cfg.test_timeout_seconds,
|
||||
"termination_grace_seconds": cfg.termination_grace_seconds,
|
||||
}
|
||||
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
@@ -163,6 +430,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "implemented", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "8 passed", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
@@ -171,7 +439,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
|
||||
result = engine.execute(issue_number=14)
|
||||
|
||||
assert result["pr_number"] == 44
|
||||
assert runner.commands[-1].startswith("git push -u origin")
|
||||
assert runner.commands[-1] == ["git", "push", "-u", "origin", "timmy/14-engine-mvp"]
|
||||
assert len(client.created_prs) == 1
|
||||
body = client.created_prs[0][4]
|
||||
assert "Closes #14" in body
|
||||
@@ -193,6 +461,51 @@ def test_dry_run_does_not_claim_or_execute(tmp_path):
|
||||
assert runner.commands == []
|
||||
|
||||
|
||||
def test_overlapping_run_fails_before_gitea_or_git_side_effects(tmp_path):
|
||||
entered = threading.Event()
|
||||
release_owner = threading.Event()
|
||||
|
||||
class BlockingClient(FakeClient):
|
||||
def __init__(self):
|
||||
super().__init__([])
|
||||
self.discovery_calls = 0
|
||||
|
||||
def list_open_issues(self, repo):
|
||||
self.discovery_calls += 1
|
||||
if self.discovery_calls > 1:
|
||||
raise AssertionError("contended run reached Gitea discovery")
|
||||
entered.set()
|
||||
release_owner.wait(timeout=2)
|
||||
raise RuntimeError("stop lease owner")
|
||||
|
||||
client = BlockingClient()
|
||||
owner_runner = FakeRunner([])
|
||||
contender_runner = FakeRunner([])
|
||||
owner = ReleaseEngine(config(tmp_path), client, owner_runner)
|
||||
contender = ReleaseEngine(config(tmp_path), client, contender_runner)
|
||||
|
||||
def run_owner():
|
||||
with pytest.raises(RuntimeError, match="stop lease owner"):
|
||||
owner.execute(dry_run=True)
|
||||
|
||||
thread = threading.Thread(target=run_owner)
|
||||
thread.start()
|
||||
assert entered.wait(timeout=1)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="release run already active") as error:
|
||||
contender.execute(dry_run=True)
|
||||
assert f"owner pid={os.getpid()}" in str(error.value)
|
||||
assert "acquired_at=" in str(error.value)
|
||||
finally:
|
||||
release_owner.set()
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert client.discovery_calls == 1
|
||||
assert owner_runner.commands == []
|
||||
assert contender_runner.commands == []
|
||||
|
||||
|
||||
def test_existing_pr_is_reused_without_agent_execution(tmp_path):
|
||||
issue = Issue(14, "Engine MVP", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue], existing_pr={"number": 9, "html_url": "https://forge/pr/9"})
|
||||
@@ -212,3 +525,251 @@ def test_state_store_round_trip_is_atomic(tmp_path):
|
||||
store.save(expected)
|
||||
assert store.load() == expected
|
||||
assert not (tmp_path / "state.json.tmp").exists()
|
||||
|
||||
|
||||
def test_concurrent_checkpoint_writers_never_collide_or_leave_partial_state(tmp_path):
|
||||
store = StateStore(tmp_path / "state.json")
|
||||
states = [
|
||||
RunState(issue=index, branch=f"timmy/{index}-engine", status="tests_passed")
|
||||
for index in range(8)
|
||||
]
|
||||
barrier = threading.Barrier(len(states))
|
||||
errors = []
|
||||
|
||||
def save_repeatedly(state):
|
||||
barrier.wait()
|
||||
for _ in range(100):
|
||||
try:
|
||||
store.save(state)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=save_repeatedly, args=(state,)) for state in states]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=5)
|
||||
|
||||
assert not any(thread.is_alive() for thread in threads)
|
||||
assert errors == []
|
||||
assert store.load() in states
|
||||
assert list(tmp_path.glob("state.json.*.tmp")) == []
|
||||
|
||||
|
||||
def test_restart_from_agent_complete_skips_claim_and_coding(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
branch = "timmy/331-resume-releases"
|
||||
cfg.state_path.write_text(json.dumps({
|
||||
"schema_version": 1,
|
||||
"repo": cfg.repo,
|
||||
"issue": issue.number,
|
||||
"branch": branch,
|
||||
"status": "agent_complete",
|
||||
"evidence": "",
|
||||
"pr_number": None,
|
||||
"pr_url": "",
|
||||
"head_sha": "abc123",
|
||||
}))
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "8 passed", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
|
||||
result = ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
assert result["tests"] == "passed"
|
||||
assert client.claimed == []
|
||||
assert ["agent", "--issue-from-env"] not in runner.commands
|
||||
assert runner.commands[:3] == [
|
||||
["git", "checkout", branch],
|
||||
["git", "rev-parse", "HEAD"],
|
||||
["git", "status", "--porcelain"],
|
||||
]
|
||||
|
||||
|
||||
def test_restart_from_tests_passed_skips_claim_coding_and_tests(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
branch = "timmy/331-resume-releases"
|
||||
StateStore(cfg.state_path).save(RunState(
|
||||
issue=issue.number,
|
||||
branch=branch,
|
||||
status="tests_passed",
|
||||
evidence="604 passed",
|
||||
repo=cfg.repo,
|
||||
head_sha="abc123",
|
||||
))
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
|
||||
result = ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
assert result["tests"] == "passed"
|
||||
assert client.claimed == []
|
||||
assert ["agent", "--issue-from-env"] not in runner.commands
|
||||
assert ["pytest", "-q"] not in runner.commands
|
||||
assert runner.commands == [
|
||||
["git", "checkout", branch],
|
||||
["git", "rev-parse", "HEAD"],
|
||||
["git", "status", "--porcelain"],
|
||||
["git", "push", "-u", "origin", branch],
|
||||
]
|
||||
|
||||
|
||||
def test_restart_from_pushed_verifies_remote_and_only_opens_pr(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
branch = "timmy/331-resume-releases"
|
||||
StateStore(cfg.state_path).save(RunState(
|
||||
issue=issue.number,
|
||||
branch=branch,
|
||||
status="pushed",
|
||||
evidence="604 passed",
|
||||
repo=cfg.repo,
|
||||
head_sha="abc123",
|
||||
))
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, f"abc123\trefs/heads/{branch}\n", ""),
|
||||
])
|
||||
|
||||
result = ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
assert result["pr_number"] == 44
|
||||
assert client.claimed == []
|
||||
assert len(client.created_prs) == 1
|
||||
assert runner.commands == [
|
||||
["git", "checkout", branch],
|
||||
["git", "rev-parse", "HEAD"],
|
||||
["git", "status", "--porcelain"],
|
||||
["git", "ls-remote", "--heads", "origin", branch],
|
||||
]
|
||||
|
||||
|
||||
def test_fresh_run_persists_repository_and_commit_identity(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], None)
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "implemented", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "604 passed", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
state = StateStore(cfg.state_path).load()
|
||||
assert state is not None
|
||||
assert state.status == "pr_opened"
|
||||
assert state.schema_version == 1
|
||||
assert state.repo == cfg.repo
|
||||
assert state.head_sha == "abc123"
|
||||
assert ["git", "rev-parse", "HEAD"] in runner.commands
|
||||
|
||||
|
||||
def test_resume_fails_closed_when_checkpoint_worktree_is_dirty(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
branch = "timmy/331-resume-releases"
|
||||
StateStore(cfg.state_path).save(RunState(
|
||||
issue=issue.number,
|
||||
branch=branch,
|
||||
status="tests_passed",
|
||||
evidence="604 passed",
|
||||
repo=cfg.repo,
|
||||
head_sha="abc123",
|
||||
))
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, " M src/release_engine.py\n", ""),
|
||||
CommandResult(0, "", ""),
|
||||
])
|
||||
|
||||
with pytest.raises(RuntimeError, match="worktree is not clean"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_resume_fails_closed_when_checkpoint_targets_another_repository(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
StateStore(cfg.state_path).save(RunState(
|
||||
issue=issue.number,
|
||||
branch="timmy/331-resume-releases",
|
||||
status="tests_passed",
|
||||
repo="other/repository",
|
||||
head_sha="abc123",
|
||||
))
|
||||
|
||||
with pytest.raises(RuntimeError, match="checkpoint does not match"):
|
||||
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=331)
|
||||
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_resume_fails_closed_when_local_commit_changed(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
StateStore(cfg.state_path).save(RunState(
|
||||
issue=issue.number,
|
||||
branch="timmy/331-resume-releases",
|
||||
status="tests_passed",
|
||||
repo=cfg.repo,
|
||||
head_sha="abc123",
|
||||
))
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "different-sha\n", ""),
|
||||
])
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit does not match the local branch"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
assert client.created_prs == []
|
||||
|
||||
|
||||
def test_resume_fails_closed_when_remote_commit_changed(tmp_path):
|
||||
issue = Issue(331, "Resume releases", "body", "open", [], "timmy")
|
||||
client = FakeClient([issue])
|
||||
cfg = config(tmp_path)
|
||||
branch = "timmy/331-resume-releases"
|
||||
StateStore(cfg.state_path).save(RunState(
|
||||
issue=issue.number,
|
||||
branch=branch,
|
||||
status="pushed",
|
||||
evidence="604 passed",
|
||||
repo=cfg.repo,
|
||||
head_sha="abc123",
|
||||
))
|
||||
runner = FakeRunner([
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, "abc123\n", ""),
|
||||
CommandResult(0, "", ""),
|
||||
CommandResult(0, f"different-sha\trefs/heads/{branch}\n", ""),
|
||||
])
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit does not match the remote branch"):
|
||||
ReleaseEngine(cfg, client, runner).execute(issue_number=331)
|
||||
|
||||
assert client.created_prs == []
|
||||
@@ -1,24 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WORKFLOW = Path(".gitea/workflows/release.yml")
|
||||
LEGACY_WORKFLOW = Path(".gitea/workflows/release.yml")
|
||||
CI_WORKFLOW = Path(".gitea/workflows/ci.yml")
|
||||
STALE_MANIFEST = Path("release/manifest.json")
|
||||
|
||||
|
||||
def test_release_workflow_uses_gitea_api_not_github_cli():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "gh release" not in text
|
||||
assert "/api/v1/repos/${{ github.repository }}/tags" in text
|
||||
assert "/api/v1/repos/${{ github.repository }}/releases" in text
|
||||
def test_independent_release_workflow_is_removed():
|
||||
assert not LEGACY_WORKFLOW.exists()
|
||||
|
||||
|
||||
def test_release_workflow_targets_merge_commit_and_marks_rc():
|
||||
text = WORKFLOW.read_text()
|
||||
assert 'TARGET="${{ github.sha }}"' in text
|
||||
assert '"prerelease":true' in text
|
||||
assert '"draft":false' in text
|
||||
def test_stale_static_release_manifest_is_removed():
|
||||
assert not STALE_MANIFEST.exists()
|
||||
|
||||
|
||||
def test_release_workflow_fails_on_api_error():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "curl --fail-with-body" in text
|
||||
assert "|| true" not in text
|
||||
def test_ci_publishes_a_draft_before_exposing_assets():
|
||||
text = CI_WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
create_draft = release.index('"draft":true')
|
||||
upload_asset = release.index("/assets?name=")
|
||||
publish = release.index('"draft":false')
|
||||
|
||||
assert create_draft < upload_asset < publish
|
||||
assert '"prerelease":true' in release
|
||||
assert 'TARGET="${{ github.sha }}"' in release
|
||||
assert "curl --fail-with-body" in release
|
||||
assert "|| true" not in release
|
||||
@@ -0,0 +1,147 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.request_boundary import RequestBodyLimitMiddleware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple"
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_declared_oversized_sign_in_is_rejected_before_throttle(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
throttle_touched = False
|
||||
|
||||
def attempt_store():
|
||||
nonlocal throttle_touched
|
||||
throttle_touched = True
|
||||
raise AssertionError("oversized request reached sign-in throttling")
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", attempt_store)
|
||||
body = b'{"access_token":"' + (b"x" * 20_000) + b'"}'
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/session",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code == 413
|
||||
assert response.json() == {"detail": "Request body too large"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert len(response.content) < 128
|
||||
assert throttle_touched is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streamed_oversized_sign_in_without_content_length_is_rejected(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
throttle_touched = False
|
||||
|
||||
def attempt_store():
|
||||
nonlocal throttle_touched
|
||||
throttle_touched = True
|
||||
raise AssertionError("oversized stream reached sign-in throttling")
|
||||
|
||||
async def chunks():
|
||||
yield b'{"access_token":"'
|
||||
yield b"x" * 10_000
|
||||
yield b"y" * 10_000
|
||||
yield b'"}'
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", attempt_store)
|
||||
request = httpx.Request(
|
||||
"POST",
|
||||
"https://test/api/v1/session",
|
||||
content=chunks(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
request.headers.pop("transfer-encoding", None)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
response = await transport.handle_async_request(request)
|
||||
await response.aread()
|
||||
|
||||
assert response.status_code == 413
|
||||
assert response.json() == {"detail": "Request body too large"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert throttle_touched is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_validation_error_never_reflects_submitted_values(access_control):
|
||||
canary = "DO-NOT-REFLECT-ACCESS-TOKEN-CANARY"
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": canary, "device_label": canary * 3},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert canary not in response.text
|
||||
details = response.json()["detail"]
|
||||
assert details
|
||||
assert all(set(item) <= {"type", "loc", "msg"} for item in details)
|
||||
|
||||
|
||||
def test_request_limits_are_route_specific_and_cover_api_mutations():
|
||||
assert main.request_body_limit("POST", "/api/v1/session") == 16 * 1024
|
||||
assert (
|
||||
main.request_body_limit("POST", "/api/v1/repos/stackchain/project/issues")
|
||||
== 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("GET", "/api/v1/context") is None
|
||||
assert main.request_body_limit("POST", "/unrelated") is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_request_limit_uses_application_path_under_domain_subpath():
|
||||
downstream_called = False
|
||||
sent = []
|
||||
|
||||
async def downstream(_scope, _receive, _send):
|
||||
nonlocal downstream_called
|
||||
downstream_called = True
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(message):
|
||||
sent.append(message)
|
||||
|
||||
middleware = RequestBodyLimitMiddleware(downstream)
|
||||
await middleware(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"root_path": "/dashboard",
|
||||
"path": "/api/v1/session",
|
||||
"headers": [(b"content-length", b"20000")],
|
||||
},
|
||||
receive,
|
||||
send,
|
||||
)
|
||||
|
||||
assert sent[0]["status"] == 413
|
||||
assert downstream_called is False
|
||||
@@ -0,0 +1,59 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
|
||||
|
||||
REQUIRED_HEADERS = {
|
||||
"x-content-type-options": "nosniff",
|
||||
"referrer-policy": "no-referrer",
|
||||
"x-frame-options": "DENY",
|
||||
}
|
||||
|
||||
|
||||
def assert_browser_security_boundary(response: httpx.Response) -> None:
|
||||
for name, value in REQUIRED_HEADERS.items():
|
||||
assert response.headers[name] == value
|
||||
assert "camera=()" in response.headers["permissions-policy"]
|
||||
policy = response.headers["content-security-policy"]
|
||||
assert "default-src 'self'" in policy
|
||||
assert "script-src 'self'" in policy
|
||||
assert "object-src 'none'" in policy
|
||||
assert "frame-ancestors 'none'" in policy
|
||||
script_policy = next(part for part in policy.split(";") if "script-src" in part)
|
||||
assert "'unsafe-inline'" not in script_policy
|
||||
assert "'unsafe-eval'" not in script_policy
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_boundary_covers_pages_health_static_and_api_errors(monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
responses = [
|
||||
await client.get("/healthz"),
|
||||
await client.get("/login"),
|
||||
await client.get("/static/dashboard.js"),
|
||||
await client.get("/api/v1/context"),
|
||||
]
|
||||
|
||||
assert [response.status_code for response in responses] == [200, 200, 200, 401]
|
||||
for response in responses:
|
||||
assert_browser_security_boundary(response)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_boundary_covers_fail_closed_authentication_response(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
response = await client.get("/login")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert_browser_security_boundary(response)
|
||||
+256
-11
@@ -13,17 +13,18 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
|
||||
}};
|
||||
const context = {{
|
||||
URL, Request, Response,
|
||||
console,
|
||||
URL, Request, Response, Headers, AbortController,
|
||||
setTimeout, clearTimeout, console,
|
||||
self: {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
@@ -34,6 +35,7 @@ const context = {{
|
||||
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
|
||||
purge: async () => {{ state.outboxPurges += 1; }},
|
||||
getReceiptPreference: async login => state.receiptLogin === login,
|
||||
}},
|
||||
}},
|
||||
@@ -44,14 +46,26 @@ const context = {{
|
||||
delete: async key => {{ state.deleted.push(key); return true; }},
|
||||
match: async request => cache.match(request),
|
||||
}},
|
||||
fetch: async request => {{
|
||||
fetch: async (request, options = {{}}) => {{
|
||||
state.fetches.push(String(request.url || request));
|
||||
if (state.failFetch) throw new Error('offline');
|
||||
return new Response('network', {{ status: state.fetchStatus }});
|
||||
if (state.stallFetch) return new Promise((resolve, reject) => {{
|
||||
options.signal?.addEventListener('abort', () => {{
|
||||
state.fetchAborted = true;
|
||||
reject(new Error('aborted'));
|
||||
}}, {{ once: true }});
|
||||
}});
|
||||
if (state.lateFetch) return new Promise(resolve => {{
|
||||
options.signal?.addEventListener('abort', () => {{ state.fetchAborted = true; }}, {{ once: true }});
|
||||
setTimeout(() => resolve(new Response('late network')), 50);
|
||||
}});
|
||||
const response = new Response('network', {{ status: state.fetchStatus }});
|
||||
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
|
||||
return response;
|
||||
}},
|
||||
}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
|
||||
async function dispatch(name, request) {{
|
||||
let pending;
|
||||
let response;
|
||||
@@ -68,9 +82,9 @@ async function dispatchSync(tag) {{
|
||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchMessage(data) {{
|
||||
async function dispatchMessage(data, ports = []) {{
|
||||
let pending;
|
||||
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route) {{
|
||||
@@ -91,10 +105,49 @@ async function dispatchNotificationClick(route) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v19" in source
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
|
||||
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
@@ -109,6 +162,43 @@ def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag(
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_background_delivery_preserves_structured_uncertain_error():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
context.fetch=async()=>new Response(JSON.stringify({detail:{code:'delivery_uncertain',message:'Verify it was not posted before retrying.'}}),{status:422,headers:{'Content-Type':'application/json'}});
|
||||
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
||||
.then(()=>({ok:true}),error=>({status:error.status,code:error.code,message:error.message}));
|
||||
process.stdout.write(JSON.stringify(outcome));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 422,
|
||||
"code": "delivery_uncertain",
|
||||
"message": "Verify it was not posted before retrying.",
|
||||
}
|
||||
|
||||
|
||||
def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_clients():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.clientMessages=[];
|
||||
state.clientList=[{postMessage:message=>state.clientMessages.push(message)}];
|
||||
context.fetch=async()=>new Response(JSON.stringify({detail:'Authentication required',code:'session_revoked'}),{status:401,headers:{'Content-Type':'application/json'}});
|
||||
const outcome=await context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST'})
|
||||
.then(()=>({ok:true}),error=>({status:error.status,code:error.code}));
|
||||
process.stdout.write(JSON.stringify({state,outcome}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
{"type": "stackchain-session-revoked"}
|
||||
]
|
||||
|
||||
|
||||
def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -120,6 +210,44 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
let mutations=0;
|
||||
context.fetch=(request, options={})=>{
|
||||
if(String(request).includes('/api/v1/session')){
|
||||
return new Promise((resolve,reject)=>options.signal?.addEventListener('abort',()=>reject(new Error('aborted')), {once:true}));
|
||||
}
|
||||
mutations+=1;
|
||||
return Promise.resolve(new Response('{}',{status:200,headers:{'Content-Type':'application/json'}}));
|
||||
};
|
||||
const controller=new AbortController();
|
||||
const request=context.self.__testWithSessionCsrf(() =>
|
||||
context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST',signal:controller.signal})
|
||||
).then(()=> 'completed', error=>error.message);
|
||||
controller.abort();
|
||||
const outcome=await Promise.race([request,new Promise(resolve=>setTimeout(()=>resolve('blocked'),40))]);
|
||||
process.stdout.write(JSON.stringify({outcome,mutations}));
|
||||
process.exit(0);
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"outcome": "aborted", "mutations": 0}
|
||||
|
||||
|
||||
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const replies = [];
|
||||
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
|
||||
process.stdout.write(JSON.stringify({state,replies}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
|
||||
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -163,11 +291,24 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
def test_background_mutations_obtain_session_bound_csrf_proof():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "async function sessionCsrf()" in source
|
||||
assert "async function sessionCsrf(signal)" in source
|
||||
assert "BASE + 'api/v1/session'" in source
|
||||
assert "signal," in source
|
||||
assert "headers.set('X-CSRF-Token', csrf)" in source
|
||||
|
||||
|
||||
def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "async function withSessionCsrf(work)" in source
|
||||
assert "batchedCsrfController = new AbortController()" in source
|
||||
assert "batchedCsrf = sessionCsrf(batchedCsrfController.signal)" in source
|
||||
assert "batchedCsrfController.abort()" in source
|
||||
assert "await (batchedCsrf || sessionCsrf(options.signal))" in source
|
||||
assert "if (options.signal?.aborted)" in source
|
||||
assert "batch: withSessionCsrf" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -181,6 +322,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
assert set(result["added"]) == {
|
||||
"/dashboard/",
|
||||
"/dashboard/manifest.webmanifest",
|
||||
"/dashboard/static/dashboard.css",
|
||||
"/dashboard/static/dashboard.js",
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
@@ -189,13 +332,21 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
"/dashboard/static/notification-read-outbox.js",
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
@@ -203,8 +354,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
@@ -240,6 +396,95 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
]
|
||||
|
||||
|
||||
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.stallFetch = true;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await Promise.race([
|
||||
dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
timedOut: response === null,
|
||||
body: response ? await response.text() : null,
|
||||
state,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["timedOut"] is False
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["fetchAborted"] is True
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_stalled_navigation_without_cached_shell_returns_deterministic_504():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.stallFetch = true;
|
||||
const response = await Promise.race([
|
||||
dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
status: response?.status || null,
|
||||
body: response ? await response.text() : null,
|
||||
contentType: response?.headers.get('Content-Type') || null,
|
||||
state,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 504
|
||||
assert result["body"] == "Stackchain is offline and the dashboard is not cached yet. Reconnect and try again."
|
||||
assert result["contentType"] == "text/plain; charset=utf-8"
|
||||
assert result["state"]["fetchAborted"] is True
|
||||
|
||||
|
||||
def test_navigation_deadline_wins_when_fetch_ignores_abort_and_prevents_late_cache_write():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.lateFetch = true;
|
||||
state.cachedBody = 'cached dashboard';
|
||||
const response = await Promise.race([
|
||||
dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate', url: 'https://forge.example/dashboard/',
|
||||
}),
|
||||
new Promise(resolve => setTimeout(() => resolve(null), 35)),
|
||||
]);
|
||||
const body = response ? await response.text() : null;
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
process.stdout.write(JSON.stringify({timedOut: response === null, body, state}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["timedOut"] is False
|
||||
assert result["body"] == "cached dashboard"
|
||||
assert result["state"]["fetchAborted"] is True
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.fetchRedirected = true;
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'navigate',
|
||||
url: 'https://forge.example/dashboard/?title=Shared',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ status: response.status, state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["status"] == 200
|
||||
assert result["state"]["puts"] == []
|
||||
|
||||
|
||||
def test_cross_origin_navigation_is_not_intercepted_or_cached():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
@@ -22,6 +22,22 @@ def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
|
||||
assert b"second-session-secret" not in database.read_bytes()
|
||||
|
||||
|
||||
def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
first = SessionStore(database, clock=lambda: now[0])
|
||||
first.activate("phone-session", 2_000)
|
||||
first.activate("laptop-session", 2_000)
|
||||
|
||||
first.revoke_all()
|
||||
|
||||
reconstructed = SessionStore(database, clock=lambda: now[0])
|
||||
assert reconstructed.is_active("phone-session", 2_000) is False
|
||||
assert reconstructed.is_active("laptop-session", 2_000) is False
|
||||
reconstructed.activate("new-session", 2_000)
|
||||
assert reconstructed.is_active("new-session", 2_000) is True
|
||||
|
||||
|
||||
def test_validation_does_not_create_a_missing_registry(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
@@ -59,3 +75,136 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
|
||||
assert store.is_active("expiring-session", 1_001) is False
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_active_devices_are_listed_without_exposing_session_secrets(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session-secret", 2_000, device_label="Pixel 9")
|
||||
store.activate("laptop-session-secret", 3_000, device_label="Work laptop")
|
||||
|
||||
devices = store.list_active("phone-session-secret")
|
||||
|
||||
assert [device.device_label for device in devices] == ["Work laptop", "Pixel 9"]
|
||||
assert [device.current for device in devices] == [False, True]
|
||||
assert all(device.management_id for device in devices)
|
||||
assert all(device.created_at == 1_000 for device in devices)
|
||||
assert "phone-session-secret" not in repr(devices)
|
||||
|
||||
|
||||
def test_revoke_managed_device_removes_only_the_selected_session(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone", 2_000, device_label="Phone")
|
||||
store.activate("laptop", 2_000, device_label="Laptop")
|
||||
phone = next(device for device in store.list_active("laptop") if device.device_label == "Phone")
|
||||
|
||||
assert store.revoke_managed(phone.management_id) is True
|
||||
assert store.is_active("phone", 2_000) is False
|
||||
assert store.is_active("laptop", 2_000) is True
|
||||
assert store.revoke_managed(phone.management_id) is False
|
||||
|
||||
|
||||
def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
digest = SessionStore._digest("existing-session")
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
|
||||
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
store.activate("new-session", 3_000, device_label="New phone")
|
||||
|
||||
assert store.is_active("existing-session", 2_000) is True
|
||||
devices = store.list_active("existing-session")
|
||||
assert len(devices) == 2
|
||||
assert next(device for device in devices if device.current).device_label == "Existing device"
|
||||
|
||||
|
||||
def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
digest = SessionStore._digest("existing-session")
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000))
|
||||
|
||||
store = SessionStore(database, clock=lambda: 1_000.0)
|
||||
grant = store.mint_step_up(
|
||||
"existing-session", action="revoke_all_sessions", target="all", ttl_seconds=90
|
||||
)
|
||||
|
||||
assert store.consume_step_up(
|
||||
grant,
|
||||
"existing-session",
|
||||
action="revoke_all_sessions",
|
||||
target="all",
|
||||
) is True
|
||||
|
||||
|
||||
def test_step_up_grant_is_single_use_and_bound_to_session_action_and_target(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 2_000)
|
||||
|
||||
grant = store.mint_step_up(
|
||||
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
|
||||
)
|
||||
|
||||
assert store.consume_step_up(
|
||||
grant, "phone-session", action="merge_pull", target="stackchain/api#8"
|
||||
) is False
|
||||
assert store.consume_step_up(
|
||||
grant, "other-session", action="merge_pull", target="stackchain/api#7"
|
||||
) is False
|
||||
assert store.consume_step_up(
|
||||
grant, "phone-session", action="close_issue", target="stackchain/api#7"
|
||||
) is False
|
||||
assert store.consume_step_up(
|
||||
grant, "phone-session", action="merge_pull", target="stackchain/api#7"
|
||||
) is True
|
||||
assert store.consume_step_up(
|
||||
grant, "phone-session", action="merge_pull", target="stackchain/api#7"
|
||||
) is False
|
||||
assert grant.encode() not in store.path.read_bytes()
|
||||
|
||||
|
||||
def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
|
||||
store.activate("phone-session", 2_000)
|
||||
expired = store.mint_step_up(
|
||||
"phone-session", action="close_issue", target="stackchain/api#7", ttl_seconds=90
|
||||
)
|
||||
revoked = store.mint_step_up(
|
||||
"phone-session", action="revoke_all_sessions", target="all", ttl_seconds=90
|
||||
)
|
||||
|
||||
now[0] = 1_091.0
|
||||
assert store.consume_step_up(
|
||||
expired, "phone-session", action="close_issue", target="stackchain/api#7"
|
||||
) is False
|
||||
|
||||
now[0] = 1_010.0
|
||||
store.revoke("phone-session")
|
||||
assert store.consume_step_up(
|
||||
revoked, "phone-session", action="revoke_all_sessions", target="all"
|
||||
) is False
|
||||
|
||||
|
||||
def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path):
|
||||
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
|
||||
store.activate("phone-session", 2_000, device_label="Phone")
|
||||
grant = store.mint_step_up(
|
||||
"phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90
|
||||
)
|
||||
phone = store.list_active("phone-session")[0]
|
||||
|
||||
assert store.revoke_managed(phone.management_id) is True
|
||||
assert store.consume_step_up(
|
||||
grant,
|
||||
"phone-session",
|
||||
action="merge_pull",
|
||||
target="stackchain/api#7",
|
||||
) is False
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -22,7 +22,9 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
|
||||
html = await dashboard()
|
||||
|
||||
assert "fetch('api/v1/live'" in html
|
||||
assert "renderLiveSnapshot(snapshot)" in html
|
||||
assert "onSnapshot: renderLiveSnapshot" in html
|
||||
assert "section + '_revision'" in html
|
||||
assert "workChanged = contextChanged || notificationsChanged" in html
|
||||
assert "renderContextSnapshot(snapshot.context)" in html
|
||||
assert "paintEventStream(snapshot.events)" in html
|
||||
assert "loadEventStream" not in html
|
||||
@@ -55,3 +57,21 @@ async def test_dashboard_reports_each_live_section_from_its_own_freshness():
|
||||
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
|
||||
assert "Unread updates unavailable · showing last known updates" in html
|
||||
assert "Activity refresh failed · showing last activity" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_refresh_and_reconnect_supersede_a_stalled_live_request():
|
||||
html = await dashboard()
|
||||
|
||||
assert "async function fetchLiveSnapshot(revisions = {}, { signal } = {})" in html
|
||||
assert "headers: { Accept: 'application/json' },\n signal," in html
|
||||
assert "function load() { return contextPoller.refresh({ force: true }); }" in html
|
||||
assert "contextPoller.refresh({ force: true });" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stalled_refresh_keeps_the_last_snapshot_with_specific_guidance():
|
||||
html = await dashboard()
|
||||
|
||||
assert "e.name === 'TimeoutError'" in html
|
||||
assert "Update delayed · showing last snapshot" in html
|
||||
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tests.dashboard_bundle import dashboard_bundle_text
|
||||
|
||||
|
||||
OVERLAY_HISTORY = Path(__file__).parents[1] / "frontend" / "task-overlay-history.js"
|
||||
|
||||
|
||||
def test_task_overlay_history_unwinds_preview_search_and_dashboard_without_duplicate_entries():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const listeners = {{}};
|
||||
const location = {{ hash:'', pathname:'/dashboard/' }};
|
||||
const stack = [{{ page:'dashboard' }}];
|
||||
let cursor = 0;
|
||||
const changes = [];
|
||||
const history = {{
|
||||
get state() {{ return stack[cursor]; }},
|
||||
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
|
||||
back() {{ if (cursor > 0) cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location,
|
||||
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
|
||||
onChange(kind, previous) {{ changes.push([kind, previous]); }},
|
||||
}});
|
||||
controller.start();
|
||||
controller.open('search');
|
||||
controller.open('search');
|
||||
controller.open('search-preview');
|
||||
controller.close();
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{changes, stack, cursor, current:controller.current()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [
|
||||
["search", None],
|
||||
["search-preview", "search"],
|
||||
["search", "search-preview"],
|
||||
[None, "search"],
|
||||
],
|
||||
"stack": [
|
||||
{"page": "dashboard"},
|
||||
{"page": "dashboard", "taskOverlay": "search"},
|
||||
{"page": "dashboard", "taskOverlay": "search-preview"},
|
||||
],
|
||||
"cursor": 0,
|
||||
"current": None,
|
||||
}
|
||||
|
||||
|
||||
def test_task_overlay_history_rejects_unknown_states_and_preserves_existing_history_state():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const pushed = [];
|
||||
const history = {{
|
||||
state: {{ workRoute:'#/my-work/issue/stackchain/api/7' }},
|
||||
pushState(state) {{ this.state = state; pushed.push(state); }}, back() {{}},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, location:{{}}, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
|
||||
}});
|
||||
const invalid = controller.open('settings');
|
||||
const valid = controller.open('new');
|
||||
process.stdout.write(JSON.stringify({{invalid, valid, pushed}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"invalid": False,
|
||||
"valid": True,
|
||||
"pushed": [{
|
||||
"workRoute": "#/my-work/issue/stackchain/api/7",
|
||||
"taskOverlay": "new",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_task_overlay_history_can_leave_an_overlay_for_a_work_route_transition():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const changes = [];
|
||||
const history = {{
|
||||
state: {{ page:'dashboard' }},
|
||||
pushState(state) {{ this.state = state; }},
|
||||
replaceState(state) {{ this.state = state; }},
|
||||
back() {{ throw new Error('route transitions must not race history.back'); }},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history, eventTarget:{{addEventListener() {{}}}},
|
||||
onChange(kind, previous) {{ changes.push([kind, previous]); }},
|
||||
}});
|
||||
controller.open('find');
|
||||
const left = controller.leave();
|
||||
process.stdout.write(JSON.stringify({{left, state:history.state, current:controller.current(), changes}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"left": True,
|
||||
"state": {"page": "dashboard"},
|
||||
"current": None,
|
||||
"changes": [["find", None], [None, "find"]],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert '<script src="static/task-overlay-history.js"></script>' in html
|
||||
assert "createTaskOverlayHistory({" in html
|
||||
assert "taskOverlayHistory.open('new')" in html
|
||||
assert "taskOverlayHistory.open('find')" in html
|
||||
assert "taskOverlayHistory.open('search')" in html
|
||||
assert "taskOverlayHistory.open('search-preview')" in html
|
||||
assert "taskOverlayHistory.close()" in html
|
||||
assert "saveIssueCaptureDraft();" in html
|
||||
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
|
||||
@@ -0,0 +1,164 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_today_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 locked")
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: BusyStore())
|
||||
payload = main.TodayOperation(
|
||||
operation_id="retry-me", action="add", item_id="issue:r:1:"
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.update_today_plan(payload)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.headers == {"Retry-After": "1"}
|
||||
|
||||
|
||||
def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, limit=3)
|
||||
|
||||
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
|
||||
"revision": 1,
|
||||
"ids": ["issue:stackchain/dashboard:1:"],
|
||||
}
|
||||
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
|
||||
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
|
||||
moved = store.apply(
|
||||
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
|
||||
)
|
||||
duplicate = store.apply(
|
||||
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
|
||||
)
|
||||
|
||||
assert moved == duplicate == {
|
||||
"revision": 4,
|
||||
"ids": [
|
||||
"issue:stackchain/dashboard:1:",
|
||||
"issue:stackchain/dashboard:3:",
|
||||
"issue:stackchain/dashboard:2:",
|
||||
],
|
||||
}
|
||||
assert TodayStore(path, limit=3).get("timmy") == moved
|
||||
assert store.get("alexander") == {"revision": 0, "ids": []}
|
||||
|
||||
|
||||
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=2)
|
||||
store.apply("timmy", "one", "add", "issue:r:1:")
|
||||
store.apply("timmy", "two", "add", "issue:r:2:")
|
||||
|
||||
with pytest.raises(TodayPlanFull):
|
||||
store.apply("timmy", "three", "add", "issue:r:3:")
|
||||
|
||||
assert store.get("timmy") == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
}
|
||||
store.apply("timmy", "remove", "remove", "issue:r:1:")
|
||||
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
|
||||
"issue:r:2:",
|
||||
"issue:r:3:",
|
||||
]
|
||||
|
||||
|
||||
def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflicts(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=2)
|
||||
|
||||
result = store.apply_batch(
|
||||
"timmy",
|
||||
[
|
||||
{"operation_id": "one", "action": "add", "item_id": "issue:r:1:"},
|
||||
{"operation_id": "two", "action": "add", "item_id": "issue:r:2:"},
|
||||
{"operation_id": "full", "action": "add", "item_id": "issue:r:3:"},
|
||||
{"operation_id": "remove", "action": "remove", "item_id": "issue:r:1:"},
|
||||
{"operation_id": "three", "action": "add", "item_id": "issue:r:3:"},
|
||||
],
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"revision": 4,
|
||||
"ids": ["issue:r:2:", "issue:r:3:"],
|
||||
"accepted_operation_ids": ["one", "two", "remove", "three"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
|
||||
}
|
||||
replay = store.apply_batch("timmy", [
|
||||
{"operation_id": "two", "action": "add", "item_id": "issue:r:2:"},
|
||||
{"operation_id": "three", "action": "add", "item_id": "issue:r:3:"},
|
||||
])
|
||||
assert replay["revision"] == 4
|
||||
assert replay["accepted_operation_ids"] == []
|
||||
assert replay["duplicate_operation_ids"] == ["two", "three"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_today_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_TODAY_DB", str(tmp_path / "today.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/today",
|
||||
json={
|
||||
"operation_id": "mobile-1",
|
||||
"action": "add",
|
||||
"item_id": "issue:stackchain/dashboard:357:",
|
||||
},
|
||||
)
|
||||
changed = await client.patch(
|
||||
"/api/v1/today",
|
||||
json={
|
||||
"operations": [
|
||||
{"operation_id": "mobile-1", "action": "add", "item_id": "issue:stackchain/dashboard:357:"},
|
||||
{"operation_id": "mobile-2", "action": "add", "item_id": "issue:stackchain/dashboard:359:"},
|
||||
],
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/today")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert changed.status_code == 200
|
||||
assert changed.json() == {
|
||||
"revision": 2,
|
||||
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
|
||||
"accepted_operation_ids": ["mobile-1", "mobile-2"],
|
||||
"duplicate_operation_ids": [],
|
||||
"rejected_operations": [],
|
||||
}
|
||||
assert fetched.json() == {"revision": 2, "ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"]}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,491 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
|
||||
COORDINATOR = Path(__file__).parents[1] / "frontend" / "outbox-coordinator.js"
|
||||
|
||||
|
||||
def test_two_tabs_cannot_erase_each_others_today_edits_or_double_drain():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_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=>createTodaySync({{storage,getLogin:()=> 'timmy',createOperationId:()=>tab+'-'+(++sequence),coordinator:createCoordinator({{storage,locks,channelFactory,tabId:tab}}),
|
||||
fetchJson:async(_url,options={{}})=>{{if(!options.method)return {{revision:0,ids:[]}};const operation=JSON.parse(options.body).operations[0];delivered.push(operation.operation_id);if(delivered.length===1)await new Promise(resolve=>releaseFirst=resolve);return {{revision:delivered.length,ids:delivered}}}},onRemoteIds:()=>{{}},onStatus:()=>{{}}}});
|
||||
const first=make('first'),second=make('second');first.enqueue('add','issue:r:1:');
|
||||
(async()=>{{const draining=first.flush();while(!releaseFirst)await Promise.resolve();second.enqueue('add','issue:r:2:');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 = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
assert sorted(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_operations_enqueued_during_an_inflight_flush_are_drained_before_it_settles():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const patches = [];
|
||||
let sequence = 0;
|
||||
let releaseFirstPatch;
|
||||
const firstPatchBlocked = new Promise(resolve => {{ globalThis.firstPatchStarted = resolve; }});
|
||||
const sync = createTodaySync({{
|
||||
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, ids:[]}};
|
||||
const operation = JSON.parse(options.body).operations[0];
|
||||
patches.push(operation);
|
||||
if (patches.length === 1) {{
|
||||
globalThis.firstPatchStarted();
|
||||
await new Promise(resolve => {{ releaseFirstPatch = resolve; }});
|
||||
}}
|
||||
return {{revision:patches.length, ids:patches.map(item => item.item_id)}};
|
||||
}},
|
||||
onRemoteIds: ids => {{ globalThis.ids = ids; }},
|
||||
onStatus: status => {{ globalThis.status = status; }},
|
||||
}});
|
||||
sync.enqueue('add', 'issue:r:1:');
|
||||
(async () => {{
|
||||
const firstFlush = sync.flush();
|
||||
await firstPatchBlocked;
|
||||
sync.enqueue('add', 'issue:r:2:');
|
||||
const sharedFlush = sync.flush();
|
||||
const sharesInflight = firstFlush === sharedFlush;
|
||||
releaseFirstPatch();
|
||||
await sharedFlush;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
sharesInflight, patches, pending:sync.pending(), ids:globalThis.ids, status:globalThis.status,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"sharesInflight": True,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "op-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||
"status": "saved",
|
||||
}
|
||||
|
||||
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v54" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_local_operations_replay_once_then_adopt_server_order():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const requests = [];
|
||||
let remote = {{revision: 1, ids:['issue:r:9:']}};
|
||||
const sync = createTodaySync({{
|
||||
storage,
|
||||
getLogin: () => 'timmy',
|
||||
createOperationId: () => 'fixed-op',
|
||||
fetchJson: async (url, options={{}}) => {{
|
||||
requests.push({{url, body: options.body && JSON.parse(options.body)}});
|
||||
if (!options.method) return remote;
|
||||
remote = {{revision: remote.revision + 1, ids:['issue:r:9:', JSON.parse(options.body).operations[0].item_id]}};
|
||||
return remote;
|
||||
}},
|
||||
onRemoteIds: ids => {{ globalThis.adopted = ids; }},
|
||||
onStatus: status => {{ globalThis.status = status; }},
|
||||
}});
|
||||
sync.enqueue('add', 'issue:r:2:');
|
||||
(async () => {{
|
||||
await sync.flush();
|
||||
await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
requests, adopted: globalThis.adopted, status: globalThis.status,
|
||||
pending: sync.pending(),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"api/v1/today",
|
||||
"api/v1/today",
|
||||
]
|
||||
assert result["requests"][0]["body"] == {"operations": [{
|
||||
"operation_id": "fixed-op",
|
||||
"action": "add",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": None,
|
||||
}]}
|
||||
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||
assert result["status"] == "saved"
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_pending_today_edits_are_sent_as_one_batch_without_a_preflight_get():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); const requests=[]; let sequence=0;
|
||||
const sync=createTodaySync({{
|
||||
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,ids:['issue:r:1:','issue:r:2:'],accepted_operation_ids:body.operations.map(x=>x.operation_id),duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteIds:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('add','issue:r:1:');sync.enqueue('add','issue:r:2:');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));}})();
|
||||
"""
|
||||
result = json.loads(subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout)
|
||||
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_failed_delivery_stays_pending_for_offline_replay():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const sync = createTodaySync({{
|
||||
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 () => {{ throw new Error('offline'); }},
|
||||
onRemoteIds: () => {{}}, onStatus: value => {{ globalThis.status = value; }},
|
||||
}});
|
||||
sync.enqueue('move', 'issue:r:2:', 'up');
|
||||
(async () => {{ await sync.flush(); process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}})); }})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"pending": [{
|
||||
"operation_id": "offline-op",
|
||||
"action": "move",
|
||||
"item_id": "issue:r:2:",
|
||||
"direction": "up",
|
||||
}],
|
||||
"status": "retrying",
|
||||
}
|
||||
|
||||
|
||||
def test_retryable_failure_replays_automatically_once_with_same_operation_id():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map(); const timers = []; const statuses = []; const patches = [];
|
||||
let attempts = 0;
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy', createOperationId:()=> 'stable-op',
|
||||
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}}, clearTimer:()=>{{}},
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
attempts += 1;
|
||||
if (attempts === 1) {{ const error = new Error('busy'); error.status=503; error.retryAfter=2; throw error; }}
|
||||
if (options.method) patches.push(...JSON.parse(options.body).operations);
|
||||
return options.method ? {{revision:1,ids:['issue:r:2:']}} : {{revision:0,ids:[]}};
|
||||
}},
|
||||
onRemoteIds:()=>{{}}, onStatus:(state,detail)=>statuses.push([state,detail?.delayMs||null]),
|
||||
}});
|
||||
sync.enqueue('add','issue:r:2:');
|
||||
(async()=>{{
|
||||
await sync.flush();
|
||||
const scheduled = timers.map(timer=>timer.delay);
|
||||
await timers[0].callback();
|
||||
process.stdout.write(JSON.stringify({{scheduled,attempts,patches,pending:sync.pending(),statuses}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result["scheduled"] == [2000]
|
||||
assert result["attempts"] == 2
|
||||
assert result["patches"] == [{
|
||||
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
|
||||
}]
|
||||
assert result["pending"] == []
|
||||
assert ["retrying", 2000] in result["statuses"]
|
||||
assert result["statuses"][-1] == ["saved", None]
|
||||
|
||||
|
||||
def test_duplicate_pending_retirements_collapse_to_one_effective_remove():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
let sequence = 0;
|
||||
const statuses = [];
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin: () => 'timmy', createOperationId: () => 'remove-' + (++sequence),
|
||||
fetchJson: async () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:value=>statuses.push(value),
|
||||
}});
|
||||
const first = sync.enqueue('remove', 'issue:r:1:');
|
||||
const duplicate = sync.enqueue('remove', 'issue:r:1:');
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, pending:sync.pending(), statuses}}));
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"first": True,
|
||||
"duplicate": True,
|
||||
"pending": [{
|
||||
"operation_id": "remove-1",
|
||||
"action": "remove",
|
||||
"item_id": "issue:r:1:",
|
||||
"direction": None,
|
||||
}],
|
||||
"statuses": ["pending", "pending"],
|
||||
}
|
||||
|
||||
|
||||
def test_existing_device_queue_is_migrated_only_once():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
let sequence = 0;
|
||||
const sync = createTodaySync({{
|
||||
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 () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
const first = sync.migrate(['issue:r:1:', 'issue:r:2:']);
|
||||
const second = sync.migrate(['issue:r:1:', 'issue:r:2:']);
|
||||
process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"pending": [
|
||||
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_server_limit_conflict_drops_rejected_add_and_adopts_server_truth():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
let patch = false;
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin: () => 'timmy', createOperationId: () => 'sixth',
|
||||
fetchJson: async (_url, options={{}}) => {{
|
||||
patch = true;
|
||||
return {{revision:5, ids:['1','2','3','4','5'], accepted_operation_ids:[], duplicate_operation_ids:[], rejected_operations:[{{operation_id:'sixth',reason:'today_full'}}]}};
|
||||
}},
|
||||
onRemoteIds:ids=>{{globalThis.ids=ids}}, onStatus:value=>{{globalThis.status=value}},
|
||||
}});
|
||||
sync.enqueue('add', '6');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{patch,pending:sync.pending(),ids:globalThis.ids,status:globalThis.status}}));}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
assert result == {
|
||||
"patch": True,
|
||||
"pending": [],
|
||||
"ids": ["1", "2", "3", "4", "5"],
|
||||
"status": "full",
|
||||
}
|
||||
|
||||
|
||||
def test_server_limit_conflict_drops_only_the_rejected_operation_and_drains_later_edits():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const patches = [];
|
||||
let sequence = 0;
|
||||
const statuses = [];
|
||||
const sync = createTodaySync({{
|
||||
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 operations = JSON.parse(options.body).operations;
|
||||
patches.push(...operations);
|
||||
return {{revision:6, ids:['1','2','3','4'], accepted_operation_ids:['op-2'], duplicate_operation_ids:[], rejected_operations:[{{operation_id:'op-1',reason:'today_full'}}]}};
|
||||
}},
|
||||
onRemoteIds: ids => {{ globalThis.ids = ids; }},
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
sync.enqueue('add', '6');
|
||||
sync.enqueue('remove', '5');
|
||||
(async()=>{{
|
||||
const result = await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{result, patches, pending:sync.pending(), ids:globalThis.ids, statuses}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"result": False,
|
||||
"patches": [
|
||||
{"operation_id": "op-1", "action": "add", "item_id": "6", "direction": None},
|
||||
{"operation_id": "op-2", "action": "remove", "item_id": "5", "direction": None},
|
||||
],
|
||||
"pending": [],
|
||||
"ids": ["1", "2", "3", "4"],
|
||||
"statuses": ["pending", "pending", "full"],
|
||||
}
|
||||
|
||||
|
||||
def test_tabs_ignore_an_older_response_after_a_newer_revision_is_broadcast():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_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: (_name, listener) => listeners.push(listener),
|
||||
postMessage: data => listeners.forEach(listener => listener({{data}})),
|
||||
}});
|
||||
let resolveOld;
|
||||
let resolveNew;
|
||||
const oldHistory = [];
|
||||
const newHistory = [];
|
||||
const oldTab = createTodaySync({{
|
||||
storage, getLogin:()=> 'timmy', createChannel,
|
||||
fetchJson:()=>new Promise(resolve=>{{resolveOld=resolve}}),
|
||||
onRemoteIds:ids=>oldHistory.push(ids), onStatus:()=>{{}},
|
||||
}});
|
||||
const newTab = createTodaySync({{
|
||||
storage, getLogin:()=> 'timmy', createChannel,
|
||||
fetchJson:()=>new Promise(resolve=>{{resolveNew=resolve}}),
|
||||
onRemoteIds:ids=>newHistory.push(ids), onStatus:()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const oldFlush = oldTab.flush();
|
||||
const newFlush = newTab.flush();
|
||||
await Promise.resolve();
|
||||
resolveNew({{revision:2,ids:['new']}});
|
||||
await newFlush;
|
||||
resolveOld({{revision:1,ids:['old']}});
|
||||
await oldFlush;
|
||||
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result["oldHistory"][-1] == ["new"]
|
||||
assert result["newHistory"][-1] == ["new"]
|
||||
|
||||
|
||||
def test_returning_online_replays_pending_operations():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const values = new Map();
|
||||
const handlers = {{}};
|
||||
const requests = [];
|
||||
let revision = 1;
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy', createOperationId:()=> 'offline-add',
|
||||
fetchJson:async (_url, options={{}})=>{{
|
||||
requests.push(options.method || 'GET');
|
||||
if (options.method) return {{revision:++revision,ids:['issue:r:1:']}};
|
||||
return {{revision,ids:[]}};
|
||||
}},
|
||||
onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('add', 'issue:r:1:');
|
||||
sync.startLifecycle({{
|
||||
window: {{addEventListener:(name, handler)=>{{handlers[name]=handler}}}},
|
||||
document: {{addEventListener:()=>{{}}, hidden:false}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await handlers.online();
|
||||
process.stdout.write(JSON.stringify({{requests,pending:sync.pending()}}));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result == {"requests": ["PATCH"], "pending": []}
|
||||
|
||||
|
||||
def test_foregrounding_a_stale_tab_refreshes_the_saved_plan():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
const handlers = {{}};
|
||||
const adopted = [];
|
||||
const documentObject = {{
|
||||
hidden:true,
|
||||
addEventListener:(name, handler)=>{{handlers[name]=handler}},
|
||||
}};
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||
getLogin:()=> 'timmy',
|
||||
fetchJson:async ()=>({{revision:7,ids:['issue:r:7:']}}),
|
||||
onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}},
|
||||
}});
|
||||
sync.startLifecycle({{
|
||||
window: {{addEventListener:()=>{{}}}}, document:documentObject,
|
||||
}});
|
||||
(async()=>{{
|
||||
await handlers.visibilitychange();
|
||||
documentObject.hidden = false;
|
||||
await handlers.visibilitychange();
|
||||
process.stdout.write(JSON.stringify(adopted));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result == [["issue:r:7:"]]
|
||||
|
||||
|
||||
def test_old_account_broadcasts_are_ignored_after_identity_changes():
|
||||
script = f"""
|
||||
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||
let login = 'timmy';
|
||||
let listener;
|
||||
const adopted = [];
|
||||
const sync = createTodaySync({{
|
||||
storage: {{getItem:()=>null,setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||
getLogin:()=>login,
|
||||
createChannel:()=>({{addEventListener:(_name, handler)=>{{listener=handler}},postMessage:()=>{{}},close:()=>{{}}}}),
|
||||
fetchJson:async ()=>({{revision:1,ids:['timmy-plan']}}),
|
||||
onRemoteIds:ids=>adopted.push(ids), onStatus:()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.flush();
|
||||
login = 'alexander';
|
||||
listener({{data:{{revision:2,ids:['timmy-secret']}}}});
|
||||
process.stdout.write(JSON.stringify(adopted));
|
||||
}})();
|
||||
"""
|
||||
result = json.loads(
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||
)
|
||||
|
||||
assert result == [["timmy-plan"]]
|
||||
@@ -0,0 +1,206 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_surfaces_automatic_planning_retry_guidance():
|
||||
html = await dashboard()
|
||||
|
||||
assert "error.retryAfter = retryAfter === null ? undefined : Number(retryAfter)" in html
|
||||
assert "Today saved on this device · retrying" in html
|
||||
assert "Later saved on this device · retrying" in html
|
||||
|
||||
|
||||
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.has(key) ? values.get(key) : null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
|
||||
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const first = queue.add(issue(1));
|
||||
const duplicate = queue.add(issue(1));
|
||||
queue.add(issue(2));
|
||||
queue.add(issue(3));
|
||||
const full = queue.add(issue(4));
|
||||
queue.move(issue(3), 'up');
|
||||
queue.move(issue(3), 'up');
|
||||
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
|
||||
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
|
||||
.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
||||
login = 'alexander';
|
||||
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"first": "added",
|
||||
"duplicate": "exists",
|
||||
"full": "full",
|
||||
"timmy": [3, 1, 2],
|
||||
"persisted": [3, 1, 2],
|
||||
"isolated": [],
|
||||
}
|
||||
|
||||
|
||||
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem: key => values.get(key) || null,
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
removeItem: key => values.delete(key),
|
||||
}};
|
||||
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
|
||||
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
|
||||
process.stdout.write(JSON.stringify([
|
||||
queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
|
||||
]));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == [
|
||||
{"can_up": False, "can_down": True},
|
||||
{"can_up": True, "can_down": True},
|
||||
{"can_up": True, "can_down": False},
|
||||
{"can_up": False, "can_down": False},
|
||||
]
|
||||
|
||||
|
||||
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||
const item = number => ({{kind:'issue', repository:'r', number}});
|
||||
queue.add(item(1));
|
||||
queue.add(item(2));
|
||||
const reports = [];
|
||||
const visible = queue.reconcile([item(2)], {{
|
||||
pruneMissing: true,
|
||||
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
|
||||
}});
|
||||
queue.reconcile([item(2)], {{
|
||||
pruneMissing: true,
|
||||
onPrune: retiredIds => reports.push({{retiredIds, idsDuringReport: queue.read()}}),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{reports, visible:visible.map(entry => entry.number), stored:queue.read()}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"reports": [{
|
||||
"retiredIds": ["issue:r:1:"],
|
||||
"idsDuringReport": ["issue:r:1:", "issue:r:2:"],
|
||||
}],
|
||||
"visible": [2],
|
||||
"stored": ["issue:r:2:"],
|
||||
}
|
||||
|
||||
|
||||
def test_failed_retirement_queue_keeps_local_identity_for_retry():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
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 queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
|
||||
const item = number => ({{kind:'issue', repository:'r', number}});
|
||||
queue.add(item(1));
|
||||
const visible = queue.reconcile([], {{pruneMissing:true, onPrune:() => false}});
|
||||
process.stdout.write(JSON.stringify({{visible, stored:queue.read()}}));
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"visible": [],
|
||||
"stored": ["issue:r:1:"],
|
||||
}
|
||||
|
||||
|
||||
def test_today_queue_adopts_bounded_server_order():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
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 queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
|
||||
queue.add({{kind:'issue', repository:'r', number:1}});
|
||||
const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
|
||||
process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"adopted": True,
|
||||
"ids": ["issue:r:3:", "issue:r:2:"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/today-work.js"></script>' in html
|
||||
assert '<script src="static/today-sync.js"></script>' in html
|
||||
assert 'data-work-filter="today"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
assert 'data-today-move="up"' in html
|
||||
assert 'data-today-move="down"' in html
|
||||
assert 'Today is limited to 5 items' in html
|
||||
assert '.today-actions button' in html and 'min-height:44px' in html
|
||||
assert 'id="today-sync-status"' in html
|
||||
assert "todaySync.enqueue('add'" in html
|
||||
assert "todaySync.enqueue('remove'" in html
|
||||
assert "todaySync.enqueue('move'" in html
|
||||
assert "const authoritativeTodayReconciliation = liveMode && hasContextSnapshot &&" in html
|
||||
assert "!lastContextSnapshot?.error &&" in html
|
||||
assert "onPrune: retiredIds =>" in html
|
||||
assert "retiredIds.map(id => todaySync.enqueue('remove', id)).every(Boolean)" in html
|
||||
assert "if (queued) todaySync.flush();" in html
|
||||
assert "todaySync.flush();" in html
|
||||
assert "Another device filled Today · showing its saved plan." in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_activates_today_lifecycle_convergence():
|
||||
html = await dashboard()
|
||||
|
||||
assert "todaySync.startLifecycle({ window, document });" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
|
||||
html = await dashboard()
|
||||
|
||||
assert "let planningOwnerLogin = '';" in html
|
||||
assert html.count("getLogin: () => planningOwnerLogin") == 4
|
||||
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
|
||||
assert "planningOwnerLogin = retainedPlanningLogin;" in html
|
||||
assert "button.disabled = !planningOwnerLogin;" in html
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in html
|
||||
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
|
||||
assert "Planning is unavailable until your operator identity is restored." in html
|
||||
assert "Could not save Today on this device." in html
|
||||
assert "Could not save Later on this device." in html
|
||||
assert "data-planning-disabled" in html
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user