Compare commits

...
Author SHA1 Message Date
timmy af86fc1e98 Merge pull request 'Attach screenshots while capturing mobile issues' (#470) from timmy/469-capture-issue-screenshots into main
CI / lint (push) Successful in 53s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 09:36:49 +00:00
timmy 5bf491c725 feat: attach screenshots during issue capture (#469)
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 09:35:09 +00:00
timmy 11341ec5d9 Merge pull request 'Attach screenshots to mobile issue comments' (#468) from timmy/467-mobile-comment-screenshots into main
CI / lint (push) Successful in 54s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 08:57:08 +00:00
timmy eefb760169 feat: attach screenshots to issue comments (#467)
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:55:13 +00:00
timmy 3f9dd49766 Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
CI / lint (push) Successful in 58s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Honor server backoff and section freshness in live polling' (#466) from timmy/465-adaptive-live-polling into main
2026-08-10 08:19:37 +00:00
timmy 72453b76db perf: adapt live polling to server freshness (#465)
CI / lint (pull_request) Successful in 55s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 08:17:57 +00:00
timmy 7639a42d7b Merge pull request 'Lock inactive dashboard sessions without losing drafts' (#464) from timmy/463-idle-session-lock into main
CI / lint (push) Successful in 54s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 07:47:07 +00:00
timmy 0b992543f1 feat: lock idle dashboard sessions (#463)
CI / lint (pull_request) Successful in 56s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:45:00 +00:00
timmy 9904f17e3d Merge pull request 'Drain authored mutations before shutdown' (#462)
CI / lint (push) Successful in 53s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
Closes #461
2026-08-10 07:14:28 +00:00
timmy 0dd3932d14 fix: drain authored actions on shutdown (#461)
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 07:12:51 +00:00
timmy 8e6ee96ede Merge pull request 'Plan Today by available time and work estimates' (#460) from timmy/459-plan-today-capacity into main
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 06:55:04 +00:00
timmy 161977cb67 feat: plan Today by available time (#459)
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:53:25 +00:00
rockachopa e3c07c2dfb Merge pull request 'Start deferred work directly from Later' (#458) from timmy/457-start-deferred-work into main
CI / lint (push) Successful in 51s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 06:03:32 +00:00
timmy a610708e36 feat: start deferred work directly (#457)
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 06:00:35 +00:00
timmy f8c5c2b058 Merge pull request 'Defer active Today work and continue to the next ready item' (#456) from timmy/455-defer-today-next into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 05:24:08 +00:00
timmy 72c470b652 feat: defer Today work and continue (#455)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 05:22:01 +00:00
timmy b214a49686 Merge pull request 'Share the Find Work catalog refresh across application workers' (#454) from timmy/453-shared-available-catalog into main
CI / lint (push) Successful in 50s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 04:44:57 +00:00
timmy a9508984e2 fix: release cancelled catalog refresh leases (#453)
CI / lint (pull_request) Successful in 50s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:43:25 +00:00
timmy 20d5176713 perf: share Find Work catalog across workers (#453)
CI / lint (pull_request) Failing after 51s
CI / build-release (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:40:15 +00:00
timmy c96ed51b0d Merge pull request 'Reply to updates and continue Today in one action' (#452)
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 04:17:31 +00:00
timmy cf2e49300a feat: reply to updates and continue Today (#451)
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 04:15:53 +00:00
timmy 5e49f27142 Merge pull request 'Continue Today after ownership exits' (#450) from timmy/449-ownership-exit-next into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 03:43:53 +00:00
timmy 0280077e45 feat: continue Today after ownership exits (#449)
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:42:01 +00:00
timmy f781410e80 Merge pull request 'Autocomplete teammate mentions in mobile conversations' (#448) from timmy/447-mobile-mentions into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 03:04:26 +00:00
timmy 0311f85d96 feat: autocomplete mobile teammate mentions (#447)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 03:02:35 +00:00
timmy 5a6ef62c4c Merge pull request 'Wrap mobile review diffs without horizontal panning' (#446) from timmy/445-wrap-mobile-review-diffs into main
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-10 02:39:38 +00:00
timmy 461c796227 feat: wrap mobile review diffs (#445)
CI / lint (pull_request) Successful in 51s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:37:43 +00:00
timmy 6309ed43cd Merge pull request 'Work Today blockers without leaving Stackchain' (#444) from timmy/443-today-blocker-preview into main
CI / lint (push) Successful in 50s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-10 02:07:06 +00:00
timmy a0aa3c0a22 feat: work Today blockers in-app (#443)
CI / lint (pull_request) Successful in 52s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 02:05:11 +00:00
timmy 60bc7cd686 Merge pull request 'Bound Today readiness latency with demand-driven checks' (#442) from timmy/441-demand-driven-today-readiness into main
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 01:35:45 +00:00
timmy 79c8f1626b perf: bound Today readiness latency (#441)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:33:55 +00:00
timmy cd9f40a1c4 Keep notification acknowledgements responsive under snapshot contention (#440)
CI / lint (push) Successful in 51s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
Closes #439
2026-08-10 01:16:02 +00:00
timmy 0a3fb180ed perf: batch notification snapshot maintenance (#439)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 01:14:11 +00:00
timmy 74d1435ca7 Merge pull request 'Skip blocked items while running the Today queue' (#438) from timmy/437-today-readiness into main
CI / lint (push) Successful in 47s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-10 00:40:44 +00:00
timmy 8989e93c2c feat: skip blocked Today work (#437)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:39:13 +00:00
timmy 7b3b7dadb9 Merge pull request 'Surface blockers while planning Today work' (#436) from timmy/435-dependency-aware-today into main
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-10 00:02:47 +00:00
timmy 05f9e06a10 feat: surface Today issue blockers (#435)
CI / lint (pull_request) Successful in 53s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-10 00:00:51 +00:00
timmy e0de45ea90 Merge pull request 'Coalesce live snapshot refreshes across application workers' (#434) from timmy/433-shared-live-snapshot into main
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
2026-08-09 23:21:58 +00:00
timmy 3224389441 perf: share live refreshes across workers (#433)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 23:20:07 +00:00
timmy 63723e7fa3 Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Precompress immutable runtime and bypass request-time gzip' (#432) from timmy/431-precompress-runtime into main
2026-08-09 22:22:03 +00:00
timmy 740e91a615 perf: precompress immutable dashboard runtime (#431)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:12:00 +00:00
timmy 0363f23cef Merge pull request 'Comment and continue to the next Today item' (#430) from timmy/429-comment-next into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 22:02:02 +00:00
timmy 2cbeba30fd feat: comment and continue Today work (#429)
CI / lint (pull_request) Successful in 50s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 22:00:14 +00:00
timmy 43595de52f Merge pull request 'Queue offline issue closure and continue Today' (#428) from timmy/427-offline-close-next into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 21:27:59 +00:00
timmy c451cd0bcb feat: queue offline issue closure and continue Today (#427)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 21:26:23 +00:00
timmy ad27db6846 Merge pull request 'Restore background delivery after sign-in following outbox purge' (#426) from timmy/425-resume-outbox-after-purge into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 20:46:12 +00:00
timmy 791082bae2 fix: resume background delivery after purge (#425)
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:44:26 +00:00
timmy d3ed0cca0c Merge pull request 'Queue offline Today reviews and continue to the next item' (#424) from timmy/423-queue-offline-review-next into main
CI / lint (push) Successful in 52s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-09 20:06:04 +00:00
timmy ccb595a081 feat: queue offline Today reviews and continue (#423)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 20:04:20 +00:00
timmy 7b1c9a608c Merge pull request 'Finish Today items without changing Gitea' (#422) from timmy/421-done-today-next into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 19:39:15 +00:00
timmy 854d040e88 feat: finish Today items without changing Gitea (#421)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:37:40 +00:00
timmy 95baa20eeb Merge pull request 'Queue completed pull request reviews for reconnect delivery' (#420)
CI / lint (push) Successful in 48s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #419
2026-08-09 19:06:15 +00:00
timmy 0cf88d7d7a feat: queue offline pull request reviews (#419)
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 19:04:28 +00:00
rockachopa 969d5567d4 Merge pull request 'Compress the mobile dashboard shell over HTTP' (#418) from timmy/417-http-compression into main
CI / lint (push) Successful in 46s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 18:35:26 +00:00
73 changed files with 7047 additions and 333 deletions

No files matched your search

+92 -22
View File
@@ -19,8 +19,18 @@ threads, create and self-assign issues, discover, claim, and release issue assig
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
inspect/comment on assigned pull
requests, merge assigned pull requests, and submit pull-request reviews.
Assigned-issue comments can include one PNG, JPEG, or WebP screenshot up to 2 MB.
The screenshot uploads before the comment is posted; validation or upload failures keep
both the typed comment and removable preview available for retry. The mobile **New issue**
sheet accepts the same image formats and stores the screenshot with its account-bound
outbox capture. Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
Pull-request replies and mobile My Work issue and PR comments use Gitea's
issue-comment API; mobile issue capture requires issue
issue-comment API. In issue, pull-request, and unread-update conversations, typing
at least two characters after `@` offers repository-scoped teammate suggestions;
touch or keyboard selection inserts the login without leaving the draft. Mention
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
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
@@ -37,10 +47,21 @@ 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. The ordered,
five-item Today plan syncs across the operator's devices. Starting a Today work session also
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
one without closing or merging it. **Reply & next** provides the same one-action continuation
for the current unread-update conversation. It deliberately leaves the notification unread;
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
@@ -70,6 +91,11 @@ not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
refresh still run immediately; a successful response resets transport backoff.
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
@@ -79,7 +105,12 @@ persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an
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
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
route deadlines. Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
five seconds to finish and persist their ledger result before it closes the Gitea transport.
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
shutdown budget; when that deadline expires, remaining operations are cancelled and their
pending reservations continue to fail closed rather than being retried automatically.
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
If contention occurs after the upstream mutation, the dashboard fails closed with
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
@@ -88,7 +119,12 @@ Approve, and Request changes reviews, and assigned-PR merge require repository
write permission. Native Comment, Approve, and Request changes reviews support
head-scoped draft comments anchored to changed lines; the dashboard validates each
comment path and submits the summary, decision, and inline comments in one review
request. The dashboard rechecks the current pull-request head, CI success, draft
request. On phone-width review sheets, changed lines wrap inside a stable old/new
line-number gutter by default so long source lines remain readable and commentable
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
horizontal inspection, and an explicit choice persists on the device across files,
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
state, and mergeability immediately before every merge.
Serve the dashboard only to trusted users on its own origin; cross-origin API
access is intentionally disabled. Authentication defaults to fail-closed
@@ -109,6 +145,9 @@ export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
# Background polling and queued delivery do not. Defaults to 15 minutes.
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
@@ -142,8 +181,9 @@ storage shared by all dashboard workers. Operators name a device at sign-in and
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.
session hashes, CSRF proofs, or source addresses. The registry also stores each
session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration.
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.
@@ -153,7 +193,12 @@ first use. Expired, replayed, cross-session, and target-substituted grants fail
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
After 15 minutes without pointer, keyboard, or touch activity (configurable through
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
polling or background delivery continued. The dashboard locks and background outbox
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
the device; signing in resumes the existing account-bound work. 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
@@ -218,12 +263,31 @@ 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`
revision tokens. Each bounded opaque token includes a per-process generation, so a token
from another worker or from before a restart cannot suppress different content. The browser
sends its known tokens on later polls, so `/api/v1/live` can omit unchanged section bodies
while still returning current freshness and retry metadata. The client retains omitted data
and only rebuilds or persists the sections that changed. Malformed or oversized tokens are
rejected before any upstream work.
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
atomically through a private SQLite store shared by all application workers. An expiring
refresh lease ensures only one worker loads currently due sections; other workers serve the
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
worker-shared writable storage. The database and directory are restricted to the service
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
an assignment invalidates the shared catalog so the newly available issue can be discovered
by the next authoritative scan. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
cannot suppress different content. The browser sends its known tokens on later polls, so
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the sections
that changed. Malformed or oversized tokens are rejected before any upstream work.
Confirmed notification acknowledgements update this worker's live view immediately, then
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
all successful notification IDs in one SQLite transaction and advance the notification
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
API keeps the confirmed result and process-local filtering rather than falsely reporting the
upstream write as failed; a later shared refresh reconciles the cache.
## Offline mobile shell
@@ -239,8 +303,11 @@ accepts a valid future local date and time and returns the item at that exact in
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
early. Later wake times are scoped to the confirmed Gitea login and synchronize
existing priority position at the wake time. **Start now** atomically admits a deferred
item to Today, removes its Later record, and opens that exact item in a resumable Today
session; full or unavailable Today storage leaves the deferral intact for retry. Items
already in Today are opened without duplication. **Bring back now** still restores an
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
and replay after reconnect. Each queued edit carries the account revision it was based
on; if another device has since changed the same item, Stackchain keeps the newer
@@ -269,11 +336,13 @@ repository catalogs, events, raw patches, and complete API responses are exclude
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.
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.
**Queue review for reconnect** durably stores that complete SHA-bound review with one
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
queued operation and its matching SHA-scoped draft and progress.
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
@@ -285,7 +354,8 @@ issue comments, pull-request comments, and unread-update replies use bounded loc
outboxes when connectivity or a retryable server failure prevents delivery. Issue
captures and authored messages are also mirrored into account-bound IndexedDB lanes
and registered with Background Sync, so a supporting installed browser can deliver
new issues, issue comments, pull-request comments, and unread-update replies after
new issues, issue comments, pull-request comments, unread-update replies, and completed
pull-request reviews after
every dashboard client has closed. The worker verifies the current Gitea login, shares
an atomic delivery claim with the foreground path, and preserves the original
idempotency key. Installed browsers can explicitly enable **Notify me when queued
+73 -11
View File
@@ -4,7 +4,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
);
const pending = new Map();
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply']);
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'pull-review', 'issue-close']);
function reviewFingerprint(message) {
return JSON.stringify({
body: String(message.body || ''),
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments : [],
});
}
function clearConfirmedReviewState(item) {
if (item.kind !== 'pull-review') return;
try {
if (item.draftKey && item.draftFingerprint &&
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
if (item.progressKey && item.progressFingerprint &&
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
}
function read() {
try {
@@ -32,6 +51,16 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const requestedOperationId = String(message.operationId || '').slice(0, 128);
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
if (existing) return { ...existing };
if (message.kind === 'pull-review') {
const queuedReview = items.find(item => item.kind === 'pull-review' &&
item.repository === String(message.repository || '') &&
item.number === Number(message.number || 0) &&
item.expectedHeadSha === String(message.expectedHeadSha || ''));
if (queuedReview) {
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
}
}
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
const id = String(requestedOperationId || makeId()).slice(0, 128);
const item = {
@@ -45,6 +74,15 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
ownerLogin,
status: 'queued',
queuedAt: Number(now()),
...(message.kind === 'pull-review' ? {
decision: String(message.decision || 'comment'),
expectedHeadSha: String(message.expectedHeadSha || ''),
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
draftKey: String(message.draftKey || ''),
progressKey: String(message.progressKey || ''),
draftFingerprint: String(message.draftFingerprint || ''),
progressFingerprint: String(message.progressFingerprint || ''),
} : {}),
};
items.push(item);
write(items, mirror);
@@ -95,6 +133,12 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
}
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
}
if (item.kind === 'pull-review') {
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
}
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
}
@@ -114,17 +158,32 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
}
result = delivery.message;
} else {
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({ body: item.body }),
});
if (item.kind === 'issue-close') {
result = await fetchJson(endpoint(item), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
});
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
} else {
const body = item.kind === 'pull-review' ? {
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments,
} : { body: item.body };
result = await fetchJson(endpoint(item), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify(body),
});
}
}
if (!result) return { blocked: true };
clearConfirmedReviewState(item);
discard(item.id);
return { result };
} catch (error) {
@@ -196,7 +255,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
if (background?.status === 'sent') return [];
if (background?.status === 'sent') {
clearConfirmedReviewState(item);
return [];
}
if (background?.status === 'attention') return [{
...item,
status: 'attention',
+123 -7
View File
@@ -141,8 +141,13 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
(current.status === 'attention' && item.status === 'attention') ||
current.status === 'sent')) return current;
await records.put({ ...item });
return item;
const next = current && current.operationId === item.operationId ? {
...item,
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
} : { ...item };
await records.put(next);
return next;
});
}
@@ -177,6 +182,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
return {
reconcile,
upsert,
update,
claim,
claimNext,
claimBatch,
@@ -205,6 +211,7 @@ function createBackgroundIssueSync({
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activePurge = null;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
@@ -242,6 +249,18 @@ function createBackgroundIssueSync({
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'pull-review') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-close') {
return {
id: item.id, status, kind: 'message',
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
@@ -260,6 +279,34 @@ function createBackgroundIssueSync({
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
}
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
if (item.kind === 'issue-close') {
return {
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
options: {
method: 'PATCH',
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
},
};
}
if (item.kind === 'pull-review') {
return {
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
options: {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
body: item.body,
decision: item.decision,
expected_head_sha: item.expectedHeadSha,
comments: item.comments || [],
}),
},
};
}
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
return authoredRequest(
@@ -302,10 +349,68 @@ function createBackgroundIssueSync({
};
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function deliverIssueCapture(item) {
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
let deliveredIssue = item.deliveredIssue;
if (!deliveredIssue) {
const request = deliveryRequest(item);
deliveredIssue = await requestJson(request.url, request.options);
await store.update?.(item.id, current => ({ ...current, deliveredIssue }));
}
let attachmentMarkdown = item.attachmentMarkdown;
if (!attachmentMarkdown) {
const uploaded = await requestJson(
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
},
);
attachmentMarkdown = String(uploaded?.markdown || '');
if (!attachmentMarkdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
await store.update?.(item.id, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
}
await requestJson(
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: attachmentMarkdown }),
},
);
return deliveredIssue;
}
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await requestJson(request.url, request.options);
const delivered = item.attachment && !item.kind ?
await deliverIssueCapture(item) : await requestJson(request.url, request.options);
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
const error = new Error('Issue closure was not confirmed.');
error.status = 422;
throw error;
}
await store.complete(item.id, delivered);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
@@ -397,15 +502,26 @@ function createBackgroundIssueSync({
return activeFlush;
}
async function purge() {
function purge() {
if (activePurge) return activePurge;
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
activePurge = (async () => {
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
})();
return activePurge;
}
async function resume() {
const pendingPurge = activePurge;
if (pendingPurge) await pendingPurge;
if (activePurge === pendingPurge) activePurge = null;
purgeRequested = false;
}
return {
flush, send, purge,
flush, send, purge, resume,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
+51
View File
@@ -0,0 +1,51 @@
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
let inFlight = null;
function submit(item, body, operationId = '') {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
let comment;
try {
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
} catch (error) {
if (!canQueue(error)) throw error;
const identity = queueKind === 'update-reply' ? {
kind: 'update-reply', notificationId: item.notification_id,
} : {
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
repository: item.repository, number: item.number,
};
const admission = await queue({
...identity, body,
operationId: typeof operationId === 'function' ? operationId() : operationId,
});
if (!admission || (!admission.item && admission.durable !== true)) {
throw new Error('Comment was not saved for delivery.');
}
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
return {
accepted: true,
delivery: admission.background ? 'queued' : 'saved',
background: Boolean(admission.background),
completed: Boolean(complete(item)),
};
}
accept(item, { delivery: 'posted', comment });
return {
accepted: true,
delivery: 'posted',
comment,
completed: Boolean(complete(item)),
};
} finally {
inFlight = null;
}
})();
return inFlight;
}
return { submit, busy: () => Boolean(inFlight) };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
+47 -4
View File
@@ -10,6 +10,13 @@ function buildLiveRevisionQuery(revisions = {}) {
return params.toString();
}
function retryAfterMs(value) {
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return null;
return Math.min(seconds * 1000, 300000);
}
function createContextPoller({
fetchContext,
onSnapshot,
@@ -21,25 +28,51 @@ function createContextPoller({
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
maxBackoffMs = 60000,
}) {
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
const sections = freshness && freshness.sections;
const sectionValues = sections && Object.values(sections);
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
const retrySeconds = Number(freshness.retry_in_seconds);
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
}
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
const healthyDeadlines = (sectionValues || [])
.filter(section => !section.degraded)
.map(section => freshForSeconds - Number(section.age_seconds))
.filter(seconds => Number.isFinite(seconds));
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
const earliestDeadline = Math.min(...healthyDeadlines);
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
return intervalMs;
}
return Math.max(0, earliestDeadline) * 1000;
}
return intervalMs;
}
function cancelTimer() {
if (timer !== null) clearTimer(timer);
timer = null;
}
function schedule() {
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
timer = setTimer(() => {
timer = null;
refresh();
}, intervalMs);
}, delayMs);
}
function abortError() {
@@ -91,6 +124,8 @@ function createContextPoller({
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
@@ -100,7 +135,14 @@ function createContextPoller({
return retainedSnapshot;
})
.catch((error) => {
if (activeRequest === requestState && !stopped) onError(error);
if (activeRequest === requestState && !stopped) {
const retryAfterMs = Number(error && error.retryAfterMs);
failureStreak += 1;
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
? retryAfterMs
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
onError(error);
}
return null;
})
.finally(() => {
@@ -108,7 +150,7 @@ function createContextPoller({
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
schedule(nextDelayMs);
});
return requestState.promise;
}
@@ -136,6 +178,7 @@ function createContextPoller({
}
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
createContextPoller.retryAfterMs = retryAfterMs;
if (typeof module !== 'undefined' && module.exports) {
module.exports = createContextPoller;
+50 -4
View File
@@ -89,6 +89,10 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
.plan-today-available span { display:flex; align-items:center; gap:6px; }
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
@@ -100,6 +104,14 @@ textarea { resize: vertical; min-height: 120px; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-readiness-header h2 { margin:.2rem 0; }
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.today-readiness-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.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; }
@@ -179,9 +191,18 @@ textarea { resize: vertical; min-height: 120px; }
.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-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
.review-display-tools button { min-height:44px; flex:0 0 auto; }
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
.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-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
.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; }
@@ -203,6 +224,8 @@ textarea { resize: vertical; min-height: 120px; }
.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-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.update-reply-actions 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; }
@@ -213,6 +236,12 @@ textarea { resize: vertical; min-height: 120px; }
.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-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
.issue-blockers h2 { margin-top:0; }
.issue-blocker-list { display:grid; gap:8px; }
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
.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%; }
@@ -226,6 +255,22 @@ textarea { resize: vertical; min-height: 120px; }
.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%; }
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.issue-attachment-controls { display:flex; max-width:100%; }
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
.issue-attachment-preview[hidden] { display:none; }
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
.mention-options[hidden] { display:none; }
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
.mention-option:last-child { border-bottom:0; }
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
.mention-status:empty { display:none; }
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.comment-actions 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; }
@@ -251,7 +296,7 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,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%; }
@@ -292,10 +337,11 @@ textarea { resize: vertical; min-height: 120px; }
@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-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; 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-attachment { display:grid; gap:8px; min-width:0; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
+720 -62
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -1,6 +1,7 @@
function createDetailDefer({
laterWork,
session,
continueSession = null,
close,
refresh,
focus,
@@ -16,6 +17,13 @@ function createDetailDefer({
return false;
}
const inSession = session.active();
if (continueSession && session.checkpointed?.(item)) {
if (continueSession(item) === true) return true;
laterWork.restore(item);
refresh();
announce('Could not remove this item from Today, so it was restored from Later.');
return false;
}
if (!inSession && closeSheet) close();
refresh();
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
+15 -3
View File
@@ -139,22 +139,34 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
.map(item => {
const isUpdate = item.kind === 'update-reply';
const routeKind = item.kind === 'pull-comment' ? 'pull' : 'issue';
const isReview = item.kind === 'pull-review';
const isClosure = item.kind === 'issue-close';
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
String(comment.path || 'File') + ' · ' +
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
': ' + String(comment.body || '')
).filter(Boolean) : [];
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
.filter(Boolean).join('\n\n');
const ownerLogin = String(item.ownerLogin || '').trim();
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
return {
id: 'stackchain.authored-outbox.v1:' + item.id,
outbox_id: item.id,
outbox_kind: item.kind,
kind: 'authored-outbox',
status: item.status === 'attention' ? 'attention' : 'queued',
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
(item.status === 'attention' ? 'Needs attention' : 'Queued message'),
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
(isReview ? 'Queued review' : (isClosure ? 'Queued issue closure' : 'Queued message'))),
delivery_state: item.deliveryState,
repository: isUpdate ? '' : item.repository,
title: target,
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
copy_text: item.body,
copy_text: copyText,
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
quarantined,
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
+81 -3
View File
@@ -180,6 +180,9 @@
<button id="cancel-plan-today" type="button">Cancel</button>
</div>
<div class="plan-today-capacity" id="plan-today-capacity" role="status" aria-live="polite">0 of 5 selected</div>
<label class="plan-today-available" for="plan-today-available">Available today
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
</label>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<section aria-labelledby="today-plan-heading">
<h3 id="today-plan-heading">Today, in order</h3>
@@ -196,6 +199,24 @@
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
<div><div class="small">Today readiness</div><h2 id="today-readiness-title">This item is blocked</h2></div>
<button id="today-readiness-close" type="button">Close</button>
</div>
<p id="today-readiness-summary" aria-live="polite"></p>
<div class="today-readiness-item" id="today-readiness-item"></div>
<div class="issue-blocker-list" id="today-readiness-blockers"></div>
<div class="today-readiness-actions">
<button id="today-readiness-next" type="button">Start next ready</button>
<button id="today-readiness-anyway" type="button">Work this item anyway</button>
<button id="today-readiness-retry" type="button">Retry status</button>
<button id="today-readiness-plan" type="button">Review Today plan</button>
</div>
</section>
</div>
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
<button id="back-to-plan" type="button">Back to plan</button>
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
@@ -262,6 +283,11 @@
<button class="issue-retry" id="retry-issue-load" type="button" hidden>Reload latest issue</button>
<div class="row"><span id="issue-labels"></span><span class="small" id="issue-assignees"></span></div>
<div class="issue-sheet-content markdown-content" id="issue-sheet-body"></div>
<section class="issue-blockers" id="issue-blockers" aria-labelledby="issue-blockers-title" hidden>
<h2 id="issue-blockers-title">Blocked by</h2>
<div class="issue-blocker-list" id="issue-blocker-list"></div>
<div id="issue-blocker-status" class="small" aria-live="polite"></div>
</section>
<h2>Full conversation</h2>
<div id="issue-comments"></div>
<button class="conversation-more" id="load-older-issue-comments" type="button" hidden>Load older messages</button>
@@ -269,7 +295,21 @@
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
<h2 id="issue-comment-title">Add comment</h2>
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-issue-comment" type="button">Post comment</button>
<div class="mention-options" id="issue-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="issue-comment-mention-status" aria-live="polite"></div>
<div class="issue-attachment-controls">
<input class="visually-hidden" id="issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
<label class="issue-attachment-trigger" for="issue-attachment">Attach screenshot</label>
</div>
<div class="issue-attachment-preview" id="issue-attachment-preview" hidden>
<img id="issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="issue-attachment-meta"></span>
<button id="remove-issue-attachment" type="button">Remove screenshot</button>
</div>
<div class="comment-actions">
<button id="send-issue-comment" type="button">Post comment</button>
<button id="send-issue-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="issue-planning" id="issue-planning">
@@ -333,6 +373,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="issue" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -377,6 +418,19 @@
<label for="create-issue-body">Description <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<div class="issue-attachment-controls">
<label class="issue-attachment-trigger" for="create-issue-attachment">Attach screenshot</label>
<input class="visually-hidden" id="create-issue-attachment" type="file" accept="image/png,image/jpeg,image/webp" />
</div>
<div class="issue-attachment-preview" id="create-issue-attachment-preview" hidden>
<img id="create-issue-attachment-image" alt="Selected screenshot preview" />
<span class="small" id="create-issue-attachment-meta"></span>
<button id="remove-create-issue-attachment" type="button">Remove</button>
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>
@@ -435,7 +489,12 @@
<h2 id="update-reply-title">Reply</h2>
<label for="update-reply" class="small">Continue this issue or pull-request conversation</label>
<textarea id="update-reply" maxlength="10000" placeholder="Write a reply"></textarea>
<button id="send-update-reply" type="button">Send reply</button>
<div class="mention-options" id="update-reply-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="update-reply-mention-status" aria-live="polite"></div>
<div class="update-reply-actions">
<button id="send-update-reply" type="button">Send reply</button>
<button id="send-update-reply-next" type="button" hidden>Reply &amp; next</button>
</div>
<div id="update-reply-status" class="small" aria-live="assertive"></div>
</section>
<div class="update-sheet-actions">
@@ -448,6 +507,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="update" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -468,7 +528,12 @@
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
<h2 id="pull-comment-title">Add comment</h2>
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
<button id="send-pull-comment" type="button">Post comment</button>
<div class="mention-options" id="pull-comment-mentions" role="listbox" aria-label="Teammates" hidden></div>
<div class="mention-status small" id="pull-comment-mention-status" aria-live="polite"></div>
<div class="comment-actions">
<button id="send-pull-comment" type="button">Post comment</button>
<button id="send-pull-comment-next" type="button" hidden>Comment &amp; next</button>
</div>
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
</section>
<details class="pull-review" id="pull-review">
@@ -488,6 +553,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="pull" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -507,6 +573,10 @@
<div class="review-sheet-body markdown-content" id="review-sheet-body"></div>
<div class="row"><span class="pill" id="review-ci-state">CI unknown</span><button class="share-work-route" type="button">Share</button><a id="open-review-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a></div>
<h2>Changed files</h2>
<div class="review-display-tools">
<button id="review-wrap-lines" type="button" aria-pressed="false" aria-controls="review-files">Wrap lines</button>
<span class="small">Keep long diffs inside the review sheet.</span>
</div>
<div id="review-files" class="muted"></div>
<section class="review-inline-composer" id="review-inline-composer" aria-labelledby="review-inline-title" hidden>
<strong id="review-inline-title">Comment on changed line</strong>
@@ -552,6 +622,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-complete="review" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</section>
@@ -590,22 +661,28 @@
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script>
<script src="static/authored-outbox.js"></script>
<script src="static/offline-issue-close.js"></script>
<script src="static/notification-read-outbox.js"></script>
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<script src="static/comment-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
<script src="static/later-sync.js"></script>
<script src="static/later-and-start.js"></script>
<script src="static/detail-defer.js"></script>
<script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/issue-attachment.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script>
@@ -622,6 +699,7 @@
<script src="static/install-app.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.issueAttachment = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const MAX_BYTES = 2 * 1024 * 1024;
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function create(options) {
const readDataUrl = options.readDataUrl;
const upload = options.upload;
let selected = null;
let confirmed = null;
let serialized = null;
function select(file) {
if (!file || !IMAGE_TYPES.has(file.type)) {
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
}
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_BYTES) {
throw new Error('Choose a screenshot that is 2 MB or smaller.');
}
selected = file;
confirmed = null;
serialized = null;
return state();
}
function clear() {
selected = null;
confirmed = null;
serialized = null;
}
function restore(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '');
const data = String(value?.data || '');
const padding = (data.match(/=*$/) || [''])[0].length;
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
select({ name: filename, type: contentType, size });
serialized = { filename, contentType, data };
return state();
}
function state() {
return selected ? {
name: selected.name,
size: selected.size,
uploaded: Boolean(confirmed),
} : null;
}
async function serialize() {
if (!selected) return null;
if (!serialized) {
const dataUrl = await readDataUrl(selected);
const marker = ';base64,';
const markerAt = String(dataUrl).indexOf(marker);
if (markerAt < 0) throw new Error('The screenshot could not be read. Choose it again.');
serialized = {
filename: selected.name,
contentType: selected.type,
data: String(dataUrl).slice(markerAt + marker.length),
};
}
return { ...serialized };
}
async function prepareComment(item, body) {
const text = String(body || '').trim();
if (!selected) return text;
if (!confirmed) {
const attachment = await serialize();
confirmed = await upload({
repository: item.repository,
number: item.number,
filename: attachment.filename,
content_type: attachment.contentType,
data: attachment.data,
});
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
confirmed = null;
throw new Error('The server did not confirm the screenshot upload.');
}
}
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
}
return { select, restore, clear, state, serialize, prepareComment };
}
function mount(options) {
const controller = create(options);
const clearSelection = controller.clear;
let previewUrl = '';
function clearPreview() {
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = '';
options.image.src = '';
options.preview.hidden = true;
options.input.value = '';
clearSelection();
}
options.input.addEventListener('change', event => {
const file = event.target.files && event.target.files[0];
try {
controller.select(file);
} catch (error) {
options.status.textContent = error.message;
options.input.value = '';
return;
}
if (previewUrl) options.revokeObjectURL(previewUrl);
previewUrl = options.createObjectURL(file);
options.image.src = previewUrl;
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
});
options.remove.addEventListener('click', () => {
clearPreview();
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
});
function restorePreview(value) {
clearPreview();
const restored = controller.restore(value);
previewUrl = 'data:' + value.contentType + ';base64,' + value.data;
options.image.src = previewUrl;
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
options.preview.hidden = false;
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
return restored;
}
return Object.assign(controller, { clear: clearPreview, restore: restorePreview });
}
return { create, mount, MAX_BYTES };
});
+90 -16
View File
@@ -5,6 +5,14 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
);
const pending = new Map();
function captureAttachment(value) {
const contentType = String(value?.contentType || '');
const filename = String(value?.filename || '').slice(0, 255);
const data = String(value?.data || '');
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType) || !data) return undefined;
return { filename, contentType, data };
}
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
@@ -40,6 +48,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
queuedAt: Number(now()),
};
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
item.operationId = item.id;
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
item.milestoneId = Number(draft.milestoneId);
@@ -76,14 +86,17 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const nextAttachment = captureAttachment(draft?.attachment);
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| attachmentChanged;
updated = {
...item,
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate,
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
@@ -91,6 +104,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
else delete updated.completionIntent;
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
if (nextAttachment === undefined) delete updated.attachment;
if (attachmentChanged) delete updated.attachmentMarkdown;
delete updated.error;
delete updated.deliveryState;
return updated;
@@ -119,6 +134,72 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return true;
}
function persistDeliveryStage(id, stage) {
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
}
function stageOperationId(operationId, stage) {
const suffix = ':' + stage;
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
}
async function sendDirect(item, repository) {
let issue = item.deliveredIssue;
if (!issue) {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
}
if (!item.attachment) return issue;
let markdown = item.attachmentMarkdown;
if (!markdown) {
const uploaded = await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
},
body: JSON.stringify({
filename: item.attachment.filename,
content_type: item.attachment.contentType,
data: item.attachment.data,
}),
},
);
markdown = String(uploaded?.markdown || '');
if (!markdown) {
const error = new Error('The server did not confirm the screenshot upload.');
error.status = 422;
throw error;
}
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
}
await fetchJson(
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
{
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
},
body: JSON.stringify({ body: markdown }),
},
);
return issue;
}
async function sendItem(item, currentLogin) {
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
if (pending.has(item.id)) return pending.get(item.id);
@@ -135,18 +216,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
issue = delivery.issue;
} else {
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
method: 'POST',
headers: {
Accept: 'application/json', 'Content-Type': 'application/json',
'Idempotency-Key': item.operationId,
},
body: JSON.stringify({
title: item.title, body: item.body, label_ids: item.labelIds,
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
}),
});
issue = await sendDirect(item, repository);
}
if (!issue) return { blocked: true };
discard(item.id);
@@ -237,6 +307,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
const statuses = new Map((records || []).map(item => [item.id, item]));
const items = read().flatMap(item => {
const background = statuses.get(item.id);
const deliveryStage = {
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
};
if (background?.status === 'sent') {
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
@@ -244,10 +318,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
return [];
}
if (background?.status === 'attention') return [{
...item, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...item, ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
}];
return [item];
return [{ ...item, ...deliveryStage }];
});
write(items);
return items;
+51
View File
@@ -0,0 +1,51 @@
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
const pending = new Map();
async function run(item, identity) {
const added = todayWork.add(item);
if (added === 'full') {
announce('Today is limited to 5 items. Remove one, then try Start now again.');
return 'full';
}
if (added !== 'added' && added !== 'exists') {
announce('Could not save Today on this device. The item remains in Later; try again.');
return added;
}
if (added === 'added' && !todaySync.enqueue('add', identity)) {
todayWork.remove(item);
announce('Today sync is unavailable. The item remains in Later; try again.');
return 'sync-unavailable';
}
if (!laterWork.restore(item)) {
if (added === 'added') {
todayWork.remove(item);
todaySync.enqueue('remove', identity);
todaySync.flush();
}
announce('Could not remove this item from Later. Nothing was started; try again.');
return 'later-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
const outcome = await start(item);
if (outcome === 'gated') {
announce('Moved to Today. Choose how to handle its blocker before starting.');
return 'gated';
}
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
return 'started';
}
function startDeferred(item) {
const identity = todayWork.identity(item);
if (pending.has(identity)) return pending.get(identity);
const operation = run(item, identity).finally(() => pending.delete(identity));
pending.set(identity, operation);
return operation;
}
return { start: startDeferred };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
+4
View File
@@ -35,6 +35,10 @@
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
return;
}
if (reason === 'session-idle') {
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
return;
}
if (reason !== 'session-revoked') return;
button.disabled = true;
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
+147
View File
@@ -0,0 +1,147 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createMentionComposer = api.create;
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
'use strict';
function activeMention(value, caret) {
const before = String(value || '').slice(0, Math.max(0, caret));
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
if (!match) return null;
const start = before.length - match[2].length - 1;
return { start, end:before.length, query:match[2] };
}
function insertMention(value, mention, login) {
const text = String(value || '');
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
const inserted = '@' + login + ' ';
return {
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
caret:mention.start + inserted.length,
};
}
function create(options) {
const textarea = options.textarea;
const listbox = options.listbox;
const status = options.status;
const setTimer = options.setTimer || setTimeout;
const clearTimer = options.clearTimer || clearTimeout;
const createOption = options.createOption || (() => document.createElement('button'));
let timer = null;
let requestId = 0;
let mention = null;
let candidates = [];
let activeIndex = -1;
function dismiss() {
requestId += 1;
candidates = [];
activeIndex = -1;
listbox.replaceChildren();
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
textarea.removeAttribute('aria-activedescendant');
status.textContent = '';
}
function activate(index) {
if (!candidates.length) return;
activeIndex = (index + candidates.length) % candidates.length;
listbox.children.forEach((option, optionIndex) => {
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
});
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
}
function select(index) {
if (!mention || !candidates[index]) return;
const result = insertMention(textarea.value, mention, candidates[index].login);
textarea.value = result.value;
textarea.setSelectionRange(result.caret, result.caret);
dismiss();
textarea.focus();
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
}
function render(items) {
candidates = Array.isArray(items) ? items : [];
activeIndex = -1;
listbox.replaceChildren();
candidates.forEach((candidate, index) => {
const option = createOption();
option.type = 'button';
option.id = listbox.id + '-option-' + index;
option.className = 'mention-option';
option.dataset.login = candidate.login;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', 'false');
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
option.addEventListener('pointerdown', event => {
event.preventDefault();
select(index);
});
listbox.appendChild(option);
});
listbox.hidden = !candidates.length;
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
}
function schedule() {
if (timer !== null) clearTimer(timer);
mention = activeMention(textarea.value, textarea.selectionStart);
const repository = options.getRepository();
if (!mention || !repository) {
dismiss();
return;
}
const currentRequest = ++requestId;
timer = setTimer(async () => {
try {
const items = await options.loadCandidates(repository, mention.query);
if (currentRequest === requestId && repository === options.getRepository()) render(items);
} catch (_error) {
if (currentRequest === requestId) {
listbox.hidden = true;
textarea.setAttribute('aria-expanded', 'false');
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
}
}
}, options.delayMs ?? 180);
}
function keydown(event) {
if (event.key === 'Escape' && !listbox.hidden) {
event.preventDefault();
dismiss();
} else if (event.key === 'ArrowDown' && candidates.length) {
event.preventDefault();
activate(activeIndex + 1);
} else if (event.key === 'ArrowUp' && candidates.length) {
event.preventDefault();
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
select(activeIndex);
}
}
return {
start() {
textarea.setAttribute('autocomplete', 'off');
textarea.setAttribute('aria-autocomplete', 'list');
textarea.setAttribute('aria-controls', listbox.id);
textarea.setAttribute('aria-expanded', 'false');
textarea.addEventListener('input', schedule);
textarea.addEventListener('click', schedule);
textarea.addEventListener('keydown', keydown);
},
dismiss,
};
}
return { activeMention, insertMention, create };
});
+38 -7
View File
@@ -563,27 +563,31 @@ function createWorkSession({
return {
active: () => running,
checkpointed: () => running && durable,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen() {
reopen(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume() {
resume(requested = null) {
const saved = checkpoint?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
const exact = items.findIndex(item => workIdentity(item) ===
(requested ? workIdentity(requested) : saved.identity));
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
@@ -609,21 +613,48 @@ function createWorkSession({
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
return index > 0 ? openAt(items, index - 1) : false;
},
next() {
next(requested = null) {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
},
complete() {
complete(requested = null) {
if (!running) return false;
const items = queue();
if (requested) {
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
}
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
if (stillPresent >= 0) {
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
}
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
},
items: () => queue().slice(),
target(action) {
const items = queue();
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
}
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
if (action === 'continue') return current >= 0 ? items[current] : null;
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
if (action === 'complete') {
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
}
return null;
},
};
}
+19
View File
@@ -0,0 +1,19 @@
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
return async function closeOfflineIssue(item) {
const admission = await enqueueDurably({
kind: 'issue-close',
repository: String(item.repository || ''),
number: Number(item.number || 0),
body: '',
operationId: String(createOperationId()).slice(0, 128),
});
const advanced = Boolean(completeToday(item, {
successMessage: 'Issue closure queued. Next Today item opened.',
failureMessage: 'Issue closure queued, but Today still needs completion.',
}));
return { admission, advanced };
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
+21 -3
View File
@@ -22,8 +22,19 @@
return true;
}
function close({ add = false } = {}) {
function setDependencies({ available, dependencies } = {}) {
if (!current) return false;
current.dependencies_available = available === true;
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
return true;
}
function close({ add = false, override = false } = {}) {
if (!current) return 'closed';
if (add && current.requires_override && !override) {
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
}
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
@@ -37,9 +48,16 @@
}
function snapshot() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
if (!current) return { open:false };
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
state.dependencies_available = current.dependencies_available;
state.dependencies = current.dependencies;
state.requires_override = current.requires_override;
}
return state;
}
return { open, close, snapshot };
return { open, close, setDependencies, snapshot };
};
});
+52 -5
View File
@@ -2,6 +2,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let openState = false;
let draftIds = [];
let itemsById = new Map();
let capacityMinutes = null;
let estimates = {};
let capacityAware = false;
function cleanItems(items) {
const unique = new Map();
@@ -12,13 +15,20 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return unique;
}
function open(selectedItems, candidates) {
function open(selectedItems, candidates, planning = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
const id = identity?.(item);
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
}
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
? planning.capacity_minutes : null;
estimates = {};
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
}
openState = true;
return snapshot();
}
@@ -50,6 +60,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
openState = false;
draftIds = [];
itemsById = new Map();
capacityMinutes = null;
estimates = {};
capacityAware = false;
}
function cancel() {
@@ -57,10 +70,31 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function commit({ start: startAfterSave = false } = {}) {
function setCapacity(minutes) {
capacityAware = true;
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
return snapshot();
}
function setEstimate(id, minutes) {
if (!openState || !draftIds.includes(id)) return false;
capacityAware = true;
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
else delete estimates[id];
return true;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
if (save?.(ids) === false) return 'unavailable';
const state = snapshot();
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
const payload = capacityAware ? {
ids,
capacity_minutes: capacityMinutes,
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
} : ids;
if (save?.(payload) === false) return 'unavailable';
const first = ids.length ? itemsById.get(ids[0]) : null;
close();
if (startAfterSave && first) start?.(first);
@@ -68,7 +102,20 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function snapshot() {
return { open: openState, ids: [...draftIds], count: draftIds.length, limit };
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
if (!capacityAware) return basic;
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
return {
...basic,
capacity_minutes: capacityMinutes,
estimates: selectedEstimates,
planned_minutes: plannedMinutes,
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
};
}
function item(id) {
@@ -79,7 +126,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, commit, snapshot, item, candidates };
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+42 -7
View File
@@ -47,6 +47,28 @@ function createReviewController({
return { load, submit };
}
function createWrapPreference({ storage, mobile }) {
const key = 'stackchain.review-wrap.v1';
let explicit = false;
let wrapped = Boolean(mobile);
try {
const saved = storage?.getItem(key);
if (saved === 'true' || saved === 'false') {
explicit = true;
wrapped = saved === 'true';
}
} catch (_error) { /* keep the viewport default */ }
function snapshot() { return { wrapped, explicit }; }
function setWrapped(value) {
wrapped = Boolean(value);
explicit = true;
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
return snapshot();
}
return { snapshot, setWrapped, storageKey: key };
}
function diffLineClass(line) {
if (line.startsWith('@@')) return 'hunk';
if (line.startsWith('+')) return 'added';
@@ -70,16 +92,19 @@ function parseDiffLines(lines) {
return { text, kind: diffLineClass(text), commentable: false };
}
if (text.startsWith('-')) {
const row = { text, kind: 'removed', commentable: true, old_position: oldLine };
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
oldLine += 1;
return row;
}
if (text.startsWith('+')) {
const row = { text, kind: 'added', commentable: true, new_position: newLine };
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
newLine += 1;
return row;
}
const row = { text, kind: 'context', commentable: true, new_position: newLine };
const row = {
text, kind: 'context', commentable: true,
old_line: oldLine, new_line: newLine, new_position: newLine,
};
oldLine += 1;
newLine += 1;
return row;
@@ -91,8 +116,14 @@ function renderDiffFile(file, index, escapeHtml) {
let preview;
if (file.diff_available) {
const lines = parseDiffLines(file.diff_lines).map(row => {
const oldNumber = row.old_line || row.old_position || '';
const newNumber = row.new_line || row.new_position || '';
const content = '<span class="review-line-numbers" aria-hidden="true">' +
'<span class="review-line-number old">' + oldNumber + '</span>' +
'<span class="review-line-number new">' + newNumber + '</span></span>' +
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
if (!row.commentable) {
return '<span class="review-diff-line ' + row.kind + '">' + escapeHtml(row.text) + '</span>';
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
}
const position = row.old_position
? ' data-old-position="' + row.old_position + '"'
@@ -100,7 +131,7 @@ function renderDiffFile(file, index, escapeHtml) {
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
(row.old_position || row.new_position) + '">' + escapeHtml(row.text) + '</button>';
(row.old_position || row.new_position) + '">' + content + '</button>';
}).join('');
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
@@ -169,7 +200,7 @@ function createProgress({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, markReviewed, clear };
return { snapshot, markReviewed, clear, storageKey: key };
}
function createDraft({ storage, repository, number, headSha, files }) {
@@ -267,7 +298,10 @@ function createDraft({ storage, repository, number, headSha, files }) {
return snapshot();
}
return { snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear };
return {
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
storageKey: key,
};
}
function formatFeedback(draft, files) {
@@ -322,6 +356,7 @@ function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }
}
createReviewController.renderDiffFile = renderDiffFile;
createReviewController.createWrapPreference = createWrapPreference;
createReviewController.parseDiffLines = parseDiffLines;
createReviewController.toggleDiff = toggleDiff;
createReviewController.createProgress = createProgress;
+20 -2
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v67';
const CACHE = 'stackchain-dashboard-shell-v81';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -21,22 +21,28 @@ const SHELL = [
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-issue-close.js',
BASE + 'static/notification-read-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
BASE + 'static/comment-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/later-and-start.js',
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
@@ -53,6 +59,7 @@ const SHELL = [
BASE + 'static/install-app.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/background-issue-sync.js',
];
@@ -109,6 +116,11 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function notifyIdleSession() {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
@@ -171,6 +183,9 @@ async function fetchJson(url, options = {}) {
if (response.status === 401 && code === 'session_revoked') {
await purgeRevokedSessionData();
}
if (response.status === 401 && code === 'session_idle') {
await notifyIdleSession();
}
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
error.status = response.status;
error.code = code;
@@ -216,7 +231,10 @@ self.addEventListener('sync', event => {
});
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
await issueSync.resume();
await flushAndNotify();
})());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
}
+41 -1
View File
@@ -15,6 +15,7 @@
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
location: root.location,
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
confirmAction: message => root.confirm(message),
promptAuthorization: details => root.prompt(
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
@@ -27,6 +28,7 @@
});
root.fetch = boundary.fetch;
const attach = () => {
boundary.startActivityHeartbeat();
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
@@ -91,16 +93,19 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener,
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
activityHeartbeatIntervalMs = 60000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
let activityHeartbeat = null;
async function handleRemoteRevocation() {
if (expirationStarted) return;
@@ -117,10 +122,19 @@
location.replace(base + 'login?reason=session-expired');
}
function handleSessionIdle() {
if (expirationStarted) return;
expirationStarted = true;
location.replace(base + 'login?reason=session-idle');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-idle') {
handleSessionIdle();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
@@ -253,6 +267,9 @@
if (payload.code === 'session_revoked') {
expirationStarted = false;
await handleRemoteRevocation();
} else if (payload.code === 'session_idle') {
expirationStarted = false;
handleSessionIdle();
} else {
expirationStarted = false;
await handleSessionExpiry();
@@ -261,6 +278,27 @@
return response;
}
function recordActivity() {
const current = now();
if (
expirationStarted
|| activityHeartbeat
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
) return Promise.resolve(false);
lastActivityHeartbeatAt = current;
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
.then(response => response.ok)
.catch(() => false)
.finally(() => { activityHeartbeat = null; });
return activityHeartbeat;
}
function startActivityHeartbeat() {
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
addActivityListener?.(type, recordActivity, { passive: true });
});
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
@@ -391,5 +429,7 @@
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
recordActivity,
startActivityHeartbeat,
};
});
+1 -1
View File
@@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
+17
View File
@@ -0,0 +1,17 @@
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
return function completeTodayItem(item, options = {}) {
if (!item || !todayWork.remove(item)) {
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
return false;
}
todaySync.enqueue('remove', todayWork.identity(item));
todaySync.flush();
refresh();
warm();
(advance || (() => workSession.complete()))();
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
return true;
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
+93
View File
@@ -0,0 +1,93 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createTodayReadiness = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
let pending = null;
let generation = 0;
async function classify(item) {
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
try {
const detail = await inspect(item);
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
const dependencies = Array.isArray(detail.dependencies)
? detail.dependencies.filter(dependency => dependency?.state === 'open')
: [];
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
} catch (_error) {
return { status:'unknown', dependencies:[] };
}
}
async function run(action, items, target = null) {
const request = ++generation;
const ordered = Array.isArray(items) ? items.slice() : [];
const preferred = target || ordered[0] || null;
if (!preferred) return 'empty';
const preferredIndex = ordered.indexOf(preferred);
const candidates = preferredIndex >= 0
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
: [preferred].concat(ordered);
const targetState = { item:preferred, ...(await classify(preferred)) };
if (request !== generation) return 'superseded';
if (targetState.status === 'ready') {
pending = null;
onOpen(action, preferred);
return 'opened';
}
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
item,
...(await classify(item)),
})));
const states = [targetState, ...fallbackStates];
if (request !== generation) return 'superseded';
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
pending = {
action,
items:ordered,
target:preferred,
status:targetState.status,
dependencies:targetState.dependencies,
nextReady:nextReadyState?.item || null,
};
onGate({ ...pending, items:pending.items.slice() });
return 'gated';
}
function open(item) {
if (!pending || !item) return false;
const action = pending.action;
pending = null;
generation += 1;
onOpen(action, item);
return true;
}
function previewBlocker(blocker) {
if (!pending || !blocker) return false;
const current = pending.dependencies.find(dependency =>
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
);
if (!current) return false;
onPreview(current);
return true;
}
return {
run,
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
previewBlocker,
startNextReady: () => open(pending?.nextReady),
workAnyway: () => open(pending?.target),
cancel() {
generation += 1;
pending = null;
},
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
};
};
});
+33 -5
View File
@@ -1,4 +1,4 @@
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId, createChannel, coordinator,
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
const prefix = 'stackchain.today-sync.v1.';
@@ -56,12 +56,19 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
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 }));
storage?.setItem(snapshotKey(), JSON.stringify({
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
}));
} 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 });
onRemotePlan?.(plan);
if (broadcast) channel?.postMessage({
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
});
return true;
}
@@ -122,7 +129,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move'].includes(operation.action) && typeof operation.item_id === 'string');
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
@@ -173,6 +180,27 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
return saved;
}
function enqueueConfiguration(capacityMinutes, estimates) {
const operation = {
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
capacity_minutes: capacityMinutes, estimates: estimates || {},
base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
@@ -253,7 +281,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus,
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, migrate, flush, pending, startLifecycle };
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+66 -6
View File
@@ -1,5 +1,6 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.v1.';
const planningPrefix = 'stackchain.today-planning.v1.';
function identity(item) {
if (!item) return '';
@@ -9,9 +10,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
function ownerKey(keyPrefix) {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
return login ? keyPrefix + encodeURIComponent(login) : '';
}
function storageKey() {
return ownerKey(prefix);
}
function planningKey() {
return ownerKey(planningPrefix);
}
function read() {
@@ -37,6 +46,40 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
}
}
function planning() {
const key = planningKey();
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
try {
const value = JSON.parse(storage.getItem(key) || 'null');
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
? value.capacity_minutes : null;
const ids = new Set(read());
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
ids.has(id) && Number.isInteger(minutes) && minutes > 0
));
return { capacity_minutes: capacity, estimates };
} catch (_error) {
return { capacity_minutes: null, estimates: {} };
}
}
function replacePlanning(value) {
const key = planningKey();
if (!key || !storage) return false;
const ids = new Set(read());
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
? value.capacity_minutes : null;
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
ids.has(id) && Number.isInteger(minutes) && minutes > 0
));
try {
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
return true;
} catch (_error) {
return false;
}
}
function add(item) {
if (!storageKey()) return 'unavailable';
const id = identity(item);
@@ -55,14 +98,18 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
unique.push(id);
}
}
return write(unique);
const saved = write(unique);
if (saved && planningKey()) replacePlanning(planning());
return saved;
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
return next.length !== ids.length && write(next);
const saved = next.length !== ids.length && write(next);
if (saved) replacePlanning(planning());
return saved;
}
function move(item, direction) {
@@ -81,7 +128,10 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
const retired = ids.filter(id => !retained.includes(id));
if (retired.length) {
const reported = onPrune?.(retired);
if (reported !== false) write(retained);
if (reported !== false) {
write(retained);
replacePlanning(planning());
}
}
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
@@ -99,7 +149,17 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
function runway(items, currentIndex = 0) {
const estimates = planning().estimates;
const remaining = (items || []).slice(Math.max(0, currentIndex));
const minutes = remaining.map(item => estimates[identity(item)] || null);
return {
current_minutes: minutes[0] || null,
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+208
View File
@@ -0,0 +1,208 @@
"""Worker-shared Find Work catalog state."""
from __future__ import annotations
import json
import os
import secrets
import stat
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
class RefreshLeaseLost(RuntimeError):
pass
@dataclass(frozen=True)
class AvailableIssueSnapshotState:
items: list[dict] | None
created_at: float | None
retry_at: float | None
refreshing: bool
lease_expires_at: float | None
class AvailableIssueSnapshotStore:
def __init__(self, path, *, clock=None):
self.path = Path(path)
self.clock = clock or time.time
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(self.path.parent, stat.S_IRWXU)
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
items_json TEXT,
created_at REAL,
retry_at REAL
);
CREATE TABLE IF NOT EXISTS available_issue_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS available_issue_claim (
repository TEXT NOT NULL,
number INTEGER NOT NULL,
PRIMARY KEY (repository, number)
);
"""
)
connection.execute(
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def _connect(self):
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def try_acquire_refresh(self, *, lease_seconds: float) -> str | None:
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO available_issue_refresh_lease VALUES (1, ?, ?)",
(owner, now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT expires_at FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?", (now,)
).fetchone()
return AvailableIssueSnapshotState(
items=json.loads(row["items_json"]) if row["items_json"] else None,
created_at=row["created_at"],
retry_at=row["retry_at"],
refreshing=lease is not None,
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish(self, owner: str | None, *, items: list[dict]) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if (
owner is None
or lease is None
or lease["owner"] != owner
or lease["expires_at"] <= now
):
connection.rollback()
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
claimed = {
(row["repository"], row["number"])
for row in connection.execute(
"SELECT repository, number FROM available_issue_claim"
)
}
items = [
item for item in items
if (item.get("repository"), item.get("number")) not in claimed
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
"retry_at = NULL WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")), now),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
def remove_claimed(self, repository: str, number: int) -> AvailableIssueSnapshotState:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"INSERT OR IGNORE INTO available_issue_claim VALUES (?, ?)",
(repository, number),
)
row = connection.execute(
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
).fetchone()
items = json.loads(row["items_json"]) if row["items_json"] else None
if items is not None:
items = [
item for item in items
if item.get("repository") != repository or item.get("number") != number
]
connection.execute(
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
(json.dumps(items, separators=(",", ":")),),
)
connection.commit()
return self.load()
def invalidate(self) -> AvailableIssueSnapshotState:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"UPDATE available_issue_snapshot SET items_json = NULL, "
"created_at = NULL, retry_at = NULL WHERE singleton = 1"
)
connection.execute("DELETE FROM available_issue_claim")
connection.commit()
return self.load()
def release_refresh(self, owner: str | None) -> None:
if owner is None:
return
with self._connect() as connection:
connection.execute(
"DELETE FROM available_issue_refresh_lease "
"WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(self, owner: str | None, *, retry_at: float) -> AvailableIssueSnapshotState:
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
).fetchone()
if (
owner is None
or lease is None
or lease["owner"] != owner
or lease["expires_at"] <= now
):
connection.rollback()
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
connection.execute(
"UPDATE available_issue_snapshot SET retry_at = ? WHERE singleton = 1",
(retry_at,),
)
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+4 -2
View File
@@ -25,7 +25,7 @@ def _quality(accept_encoding: str, coding: str) -> float:
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
@@ -36,7 +36,9 @@ class NegotiatedGZipMiddleware:
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
if _quality(accepted, "gzip") > 0:
path = scope.get("path", "").rsplit("/", 1)[-1]
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
+30 -1
View File
@@ -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
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
STEP_UP_TTL_SECONDS = 90
MIN_SECRET_LENGTH = 24
OPERATOR_MODE = "operator"
@@ -96,6 +97,18 @@ def _session_store(now: int | None = None) -> SessionStore:
return SessionStore(database, clock=current)
def idle_timeout_seconds() -> int:
return max(
1,
int(
os.getenv(
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
)
),
)
def issue_session(
now: int | None = None, *, device_label: str = "This device"
) -> tuple[str, Session]:
@@ -146,7 +159,14 @@ def verify_session_with_reason(
or not session.csrf
):
return SessionVerification(None)
if not _session_store(now).is_active(session.session_id, session.expires_at):
status = _session_store(now).status(
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
if status == "idle":
return SessionVerification(None, "session_idle")
if status != "active":
return SessionVerification(None, "session_revoked")
return SessionVerification(session)
@@ -167,6 +187,15 @@ async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
session.session_id,
session.expires_at,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def revoke_managed_session(management_id: str) -> bool:
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
+4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import gzip
import hashlib
import re
from dataclasses import dataclass
@@ -16,6 +17,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
class FrontendBuild:
dashboard_html: str
runtime_bytes: bytes
runtime_gzip_bytes: bytes
runtime_digest: str
runtime_name: str
page_sources: tuple[str, ...]
@@ -38,6 +40,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
raise ValueError("dashboard entry document has no page scripts")
runtime = _bundle(frontend_dir, sources)
runtime_gzip = gzip.compress(runtime, compresslevel=6, mtime=0)
digest = hashlib.sha256(runtime).hexdigest()[:16]
runtime_name = f"runtime-{digest}.js"
dashboard_html = SCRIPT_TAG.sub("", source_html)
@@ -63,6 +66,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
return FrontendBuild(
dashboard_html=dashboard_html,
runtime_bytes=runtime,
runtime_gzip_bytes=runtime_gzip,
runtime_digest=digest,
runtime_name=runtime_name,
page_sources=sources,
+105 -2
View File
@@ -878,6 +878,33 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
return _normalize_issue_comment(comment)
async def upload_assigned_issue_attachment(
repository: str,
number: int,
filename: str,
content_type: str,
content: bytes,
) -> dict:
if not await is_assigned_issue(repository, number):
raise IssueNotAvailableError("Assigned issue not found")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/assets",
headers=_auth(),
params={"name": filename},
files={"attachment": (filename, content, content_type)},
)
response.raise_for_status()
attachment = response.json()
if not isinstance(attachment, dict):
raise ValueError("Gitea attachment response was not an object")
name = attachment.get("name")
url = _safe_web_url(attachment.get("browser_download_url"))
size = attachment.get("size")
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
raise ValueError("Gitea did not confirm the attachment")
return {"name": name, "url": url, "size": size}
async def repo_labels(repository: str) -> list[dict]:
response = await _get_client().get(
f"/api/v1/repos/{repository}/labels",
@@ -1129,6 +1156,40 @@ async def issue_handoff_candidates(repository: str) -> list[dict]:
return candidates
async def mention_candidates(
repository: str, query: str, *, limit: int = 8
) -> list[dict]:
response = await _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")
needle = query.casefold()
matches = []
for item in payload:
if not isinstance(item, dict):
continue
login = item.get("login")
if (
not isinstance(login, str)
or not re.fullmatch(r"[A-Za-z0-9_.-]+", login)
):
continue
full_name = item.get("full_name")
name = full_name if isinstance(full_name, str) and full_name else login
login_match = needle in login.casefold()
name_match = needle in name.casefold()
if login_match or name_match:
matches.append((0 if login.casefold().startswith(needle) else 1, login.casefold(), {
"login": login,
"name": name,
}))
matches.sort(key=lambda item: (item[0], item[1]))
return [item[2] for item in matches[:limit]]
async def handoff_assigned_issue(
repository: str, number: int, recipient: str
) -> dict:
@@ -1343,9 +1404,19 @@ async def issue_conversation_page(
async def issue_detail(repository: str, number: int) -> dict:
base = f"repos/{repository}/issues/{number}"
issue, conversation = await asyncio.gather(
fetch(base), issue_conversation_page(repository, number)
async def load_dependencies() -> tuple[bool, list[dict]]:
try:
return True, await issue_dependencies(repository, number)
except Exception:
return False, []
issue, conversation, dependency_result = await asyncio.gather(
fetch(base),
issue_conversation_page(repository, number),
load_dependencies(),
)
dependencies_available, dependencies = dependency_result
if not isinstance(issue, dict):
raise ValueError("Gitea issue response was not an object")
labels_value = issue.get("labels")
@@ -1377,11 +1448,43 @@ async def issue_detail(repository: str, number: int) -> dict:
assignee["login"] for assignee in assignees
if isinstance(assignee, dict) and isinstance(assignee.get("login"), str)
],
"dependencies_available": dependencies_available,
"dependencies": dependencies,
"comments": normalized_comments,
"conversation": conversation,
}
async def issue_dependencies(repository: str, number: int, limit: int = 20) -> list[dict]:
"""Return bounded open prerequisites for an issue."""
response = await _get_client().get(
f"/api/v1/repos/{repository}/issues/{number}/dependencies",
headers=_auth(),
params={"limit": limit},
)
response.raise_for_status()
value = response.json()
items = value if isinstance(value, list) else []
dependencies = []
for item in items[:limit]:
if not isinstance(item, dict) or item.get("state") != "open":
continue
repo_value = item.get("repository")
repo = repo_value if isinstance(repo_value, dict) else {}
dependency_repository = repo.get("full_name")
dependency_number = item.get("number")
if not isinstance(dependency_repository, str) or not isinstance(dependency_number, int):
continue
dependencies.append({
"repository": dependency_repository,
"number": dependency_number,
"title": item.get("title", "") if isinstance(item.get("title"), str) else "",
"state": "open",
"url": _safe_web_url(item.get("html_url")),
})
return dependencies
async def update_assigned_issue(
repository: str,
number: int,
+295
View File
@@ -0,0 +1,295 @@
"""Process-shared live snapshot state and refresh coordination."""
from __future__ import annotations
import json
import os
import secrets
import sqlite3
import stat
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable
SECTIONS = ("context", "events", "notifications")
class RefreshLeaseLost(RuntimeError):
"""The refresh is no longer authorized to publish shared state."""
@dataclass(frozen=True)
class LiveSnapshotState:
value: dict | None
created_at: dict[str, float | None]
failure_count: dict[str, int]
retry_at: dict[str, float | None]
revisions: dict[str, int]
generation: str
refreshing_sections: set[str]
lease_expires_at: float | None
class LiveSnapshotStore:
"""A private SQLite snapshot with an expiring, cross-process refresh lease."""
def __init__(
self,
path: str | os.PathLike[str],
*,
clock: Callable[[], float] | None = None,
):
self.path = Path(path)
self.clock = clock or time.time
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 1000")
return connection
def _initialize(self) -> None:
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
os.chmod(self.path.parent, stat.S_IRWXU)
except OSError:
pass
old_umask = os.umask(0o077)
try:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS live_snapshot (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
value_json TEXT,
created_at_json TEXT NOT NULL,
failure_count_json TEXT NOT NULL,
retry_at_json TEXT NOT NULL,
revisions_json TEXT NOT NULL,
generation TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS live_refresh_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
owner TEXT NOT NULL,
sections_json TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS live_read_notification (
notification_id INTEGER PRIMARY KEY
);
"""
)
connection.execute(
"""INSERT OR IGNORE INTO live_snapshot VALUES
(1, NULL, ?, ?, ?, ?, ?)""",
(
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
json.dumps({section: None for section in SECTIONS}),
json.dumps({section: 0 for section in SECTIONS}),
secrets.token_hex(8),
),
)
finally:
os.umask(old_umask)
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
def try_acquire_refresh(
self, sections: Iterable[str], *, lease_seconds: float
) -> str | None:
requested = sorted(set(sections))
if not requested or any(section not in SECTIONS for section in requested):
raise ValueError("refresh lease requires known sections")
if lease_seconds <= 0:
raise ValueError("refresh lease duration must be positive")
now = self.clock()
owner = secrets.token_hex(16)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
active = connection.execute(
"SELECT expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if active is not None and active["expires_at"] > now:
connection.rollback()
return None
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.execute(
"INSERT INTO live_refresh_lease VALUES (1, ?, ?, ?)",
(owner, json.dumps(requested, separators=(",", ":")), now + lease_seconds),
)
connection.commit()
return owner
def load(self) -> LiveSnapshotState:
now = self.clock()
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM live_snapshot WHERE singleton = 1"
).fetchone()
lease = connection.execute(
"SELECT sections_json, expires_at FROM live_refresh_lease "
"WHERE singleton = 1 AND expires_at > ?",
(now,),
).fetchone()
assert row is not None
return LiveSnapshotState(
value=json.loads(row["value_json"]) if row["value_json"] is not None else None,
created_at=json.loads(row["created_at_json"]),
failure_count=json.loads(row["failure_count_json"]),
retry_at=json.loads(row["retry_at_json"]),
revisions=json.loads(row["revisions_json"]),
generation=row["generation"],
refreshing_sections=set(json.loads(lease["sections_json"])) if lease else set(),
lease_expires_at=lease["expires_at"] if lease else None,
)
def publish_refresh(
self,
owner: str,
*,
value: dict,
created_at: dict[str, float | None],
failure_count: dict[str, int],
retry_at: dict[str, float | None],
changed_sections: Iterable[str],
) -> LiveSnapshotState:
changed = set(changed_sections)
if changed - set(SECTIONS):
raise ValueError("publication contains unknown sections")
if any(set(mapping) != set(SECTIONS) for mapping in (created_at, failure_count, retry_at)):
raise ValueError("publication metadata must contain every section")
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
row = connection.execute(
"SELECT revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
revisions = json.loads(row["revisions_json"])
notifications = value.get("notifications")
if isinstance(notifications, list):
returned_ids = {
item.get("id") for item in notifications if isinstance(item, dict)
}
read_ids = {
item["notification_id"]
for item in connection.execute(
"SELECT notification_id FROM live_read_notification"
)
}
value = dict(value)
value["notifications"] = [
item for item in notifications
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if returned_ids:
placeholders = ",".join("?" for _ in returned_ids)
connection.execute(
f"DELETE FROM live_read_notification WHERE notification_id NOT IN ({placeholders})",
tuple(returned_ids),
)
else:
connection.execute("DELETE FROM live_read_notification")
for section in changed:
revisions[section] += 1
connection.execute(
"""UPDATE live_snapshot SET value_json = ?, created_at_json = ?,
failure_count_json = ?, retry_at_json = ?, revisions_json = ?
WHERE singleton = 1""",
(
json.dumps(value, separators=(",", ":")),
json.dumps(created_at, separators=(",", ":")),
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?", (owner,)
)
connection.commit()
return self.load()
def remove_notifications(self, notification_ids: Iterable[int]) -> LiveSnapshotState:
"""Filter confirmed reads from every worker and subsequent stale refreshes."""
read_ids = set(notification_ids)
if not read_ids:
return self.load()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.executemany(
"INSERT OR IGNORE INTO live_read_notification VALUES (?)",
((notification_id,) for notification_id in read_ids),
)
row = connection.execute(
"SELECT value_json, revisions_json FROM live_snapshot WHERE singleton = 1"
).fetchone()
value = json.loads(row["value_json"]) if row["value_json"] else None
revisions = json.loads(row["revisions_json"])
if value is not None and isinstance(value.get("notifications"), list):
previous = value["notifications"]
retained = [
item
for item in previous
if not isinstance(item, dict) or item.get("id") not in read_ids
]
if retained != previous:
value["notifications"] = retained
revisions["notifications"] += 1
connection.execute(
"UPDATE live_snapshot SET value_json = ?, revisions_json = ? WHERE singleton = 1",
(
json.dumps(value, separators=(",", ":")),
json.dumps(revisions, separators=(",", ":")),
),
)
connection.commit()
return self.load()
def remove_notification(self, notification_id: int) -> LiveSnapshotState:
"""Filter one confirmed read from every worker and subsequent stale refresh."""
return self.remove_notifications([notification_id])
def release_refresh(self, owner: str) -> None:
"""Relinquish a lease that became unnecessary after a coherent recheck."""
with self._connect() as connection:
connection.execute(
"DELETE FROM live_refresh_lease WHERE singleton = 1 AND owner = ?",
(owner,),
)
def fail_refresh(
self,
owner: str,
*,
failure_count: dict[str, int],
retry_at: dict[str, float | None],
) -> LiveSnapshotState:
"""Publish backoff metadata and relinquish a failed refresh atomically."""
now = self.clock()
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
lease = connection.execute(
"SELECT owner, expires_at FROM live_refresh_lease WHERE singleton = 1"
).fetchone()
if lease is None or lease["owner"] != owner or lease["expires_at"] <= now:
connection.rollback()
raise RefreshLeaseLost("live refresh lease expired or changed owner")
connection.execute(
"""UPDATE live_snapshot SET failure_count_json = ?, retry_at_json = ?
WHERE singleton = 1""",
(
json.dumps(failure_count, separators=(",", ":")),
json.dumps(retry_at, separators=(",", ":")),
),
)
connection.execute("DELETE FROM live_refresh_lease WHERE singleton = 1")
connection.commit()
return self.load()
+470 -61
View File
@@ -1,4 +1,6 @@
import asyncio
import base64
import binascii
import hmac
import math
import os
@@ -19,6 +21,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import dashboard_auth, gitea_proxy
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
from src.compression import NegotiatedGZipMiddleware
from src.gitea_proxy import (
activity_events,
@@ -34,6 +37,7 @@ from src.gitea_proxy import (
)
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
@@ -41,6 +45,28 @@ from src.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore
from src.views import FRONTEND_BUILD, router as frontend_router
async def _drain_authored_action_operations() -> None:
tasks = {
operation[1]
for operation in _authored_action_operations.values()
if not operation[1].done()
}
if not tasks:
return
try:
await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS,
)
except TimeoutError:
pass
finally:
for key, operation in list(_authored_action_operations.items()):
if operation[1] in tasks and operation[1].done():
_authored_action_operations.pop(key, None)
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _live_snapshot_task, _available_issue_snapshot_task
@@ -58,6 +84,7 @@ async def lifespan(_app: FastAPI):
except asyncio.CancelledError:
pass
try:
await _drain_authored_action_operations()
await gitea_proxy.stop_client()
finally:
if _live_snapshot_task is live_task:
@@ -81,6 +108,9 @@ AUTHORED_ACTION_IDEMPOTENCY_MAX_ENTRIES = 256
AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS = float(
os.getenv("STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS", "0.1")
)
AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS = float(
os.getenv("STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", "5.0")
)
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
NOTIFICATION_PAGE_TIMEOUT_SECONDS = 5.0
WORK_PAGE_TIMEOUT_SECONDS = 5.0
@@ -93,6 +123,7 @@ LIVE_SNAPSHOT_RETRY_BASE_SECONDS = 5.0
LIVE_SNAPSHOT_RETRY_MAX_SECONDS = 60.0
AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS = 15.0
AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0
AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
@@ -117,6 +148,11 @@ _authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
] = {}
_state_dir = Path(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"))
_live_snapshot_clock = time.time
_live_snapshot_store = LiveSnapshotStore(
os.getenv("STACKCHAIN_LIVE_SNAPSHOT_DB", str(_state_dir / "live-snapshot.sqlite3")),
clock=lambda: _live_snapshot_clock(),
)
_idempotency_ledger = IdempotencyLedger(
os.getenv("STACKCHAIN_IDEMPOTENCY_DB", str(_state_dir / "idempotency.sqlite3")),
ttl_seconds=AUTHORED_ACTION_IDEMPOTENCY_TTL_SECONDS,
@@ -127,9 +163,16 @@ _idempotency_ledger = IdempotencyLedger(
lock_timeout_seconds=AUTHORED_ACTION_LEDGER_LOCK_TIMEOUT_SECONDS,
)
_available_issue_snapshot_task: asyncio.Task | None = None
_available_issue_snapshot_lock = asyncio.Lock()
_available_issue_snapshot_value: list[dict] | None = None
_available_issue_snapshot_created_at: float | None = None
_available_issue_snapshot_retry_at: float | None = None
_available_issue_snapshot_store = AvailableIssueSnapshotStore(
os.getenv(
"STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB",
str(_state_dir / "available-issue-snapshot.sqlite3"),
)
)
class ContextPayloadError(ValueError):
@@ -208,17 +251,24 @@ class NotificationReadBatch(BaseModel):
class TodayOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["add", "remove", "move"]
action: Literal["add", "remove", "move", "configure"]
item_id: str = Field(min_length=1, max_length=500)
direction: Literal["up", "down"] | None = None
base_revision: int | None = Field(default=None, ge=0)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
@model_validator(mode="after")
def require_move_direction(self):
def validate_action_fields(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")
if self.action != "configure" and (self.capacity_minutes is not None or self.estimates):
raise ValueError("capacity and estimates are only valid for configure")
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
for item_id, minutes in self.estimates.items()):
raise ValueError("estimates must use bounded item IDs and minutes")
return self
@@ -262,6 +312,46 @@ class IssueComment(BaseModel):
return value
class IssueAttachment(BaseModel):
filename: str = Field(min_length=1, max_length=255)
content_type: Literal["image/png", "image/jpeg", "image/webp"]
data: str = Field(min_length=1, max_length=2_800_000)
@model_validator(mode="after")
def validate_filename(self):
if (
"/" in self.filename
or "\\" in self.filename
or any(ord(character) < 32 for character in self.filename)
):
raise ValueError("attachment filename must be a plain file name")
extension = self.filename.rsplit(".", 1)[-1].lower() if "." in self.filename else ""
expected = {
"image/png": {"png"},
"image/jpeg": {"jpg", "jpeg"},
"image/webp": {"webp"},
}
if extension not in expected[self.content_type]:
raise ValueError("attachment filename must match the selected image type")
return self
def content(self) -> bytes:
try:
content = base64.b64decode(self.data, validate=True)
except (ValueError, binascii.Error) as exc:
raise ValueError("attachment data must be valid base64") from exc
if len(content) > 2 * 1024 * 1024:
raise ValueError("screenshot must be 2 MB or smaller")
signatures = {
"image/png": content.startswith(b"\x89PNG\r\n\x1a\n"),
"image/jpeg": content.startswith(b"\xff\xd8\xff"),
"image/webp": content.startswith(b"RIFF") and content[8:12] == b"WEBP",
}
if not signatures[self.content_type]:
raise ValueError("file contents do not match the selected image type")
return content
class IssueCreation(BaseModel):
title: str = Field(min_length=1, max_length=255)
body: str = Field(default="", max_length=10_000)
@@ -622,18 +712,19 @@ 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":
if session_reason in {"session_revoked", "session_idle"}:
payload["code"] = session_reason
return JSONResponse(
payload,
status_code=401,
headers={"Cache-Control": "no-store"},
)
login_redirect = (
"login?reason=session-revoked"
if session_reason == "session_revoked"
else _share_target_login_redirect(request)
)
if session_reason == "session_revoked":
login_redirect = "login?reason=session-revoked"
elif session_reason == "session_idle":
login_redirect = "login?reason=session-idle"
else:
login_redirect = _share_target_login_redirect(request)
return RedirectResponse(
login_redirect,
status_code=303,
@@ -889,6 +980,29 @@ async def session_status(request: Request):
return payload
@app.post("/api/v1/session/activity")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
try:
active = await dashboard_auth.touch_session(session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
if not active:
return JSONResponse(
{"detail": "Authentication required"},
status_code=401,
headers={"Cache-Control": "no-store"},
)
return {
"active": True,
"idle_expires_at": int(time.time()) + dashboard_auth.idle_timeout_seconds(),
}
def _today_store() -> TodayStore:
return TodayStore(
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
@@ -939,6 +1053,12 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
login,
[operation.model_dump() for operation in payload.operations],
)
if payload.action == "configure":
return await asyncio.to_thread(
_today_store().apply_batch,
login,
[payload.model_dump()],
)
return await asyncio.to_thread(
_today_store().apply,
login,
@@ -1359,14 +1479,33 @@ async def paged_work(
})
async def _refresh_available_issue_snapshot() -> list[dict]:
async def _refresh_available_issue_snapshot(lease_owner: str) -> list[dict]:
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
global _available_issue_snapshot_retry_at
result = await gitea_proxy.available_issue_snapshot()
_available_issue_snapshot_value = result
_available_issue_snapshot_created_at = time.monotonic()
_available_issue_snapshot_retry_at = None
return result
try:
result = await gitea_proxy.available_issue_snapshot()
shared = await asyncio.to_thread(
_available_issue_snapshot_store.publish, lease_owner, items=result
)
_available_issue_snapshot_value = shared.items
_available_issue_snapshot_created_at = time.monotonic()
_available_issue_snapshot_retry_at = None
return shared.items or []
except asyncio.CancelledError:
await asyncio.to_thread(
_available_issue_snapshot_store.release_refresh, lease_owner
)
raise
except Exception:
try:
await asyncio.to_thread(
_available_issue_snapshot_store.fail_refresh,
lease_owner,
retry_at=time.time() + AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS,
)
except Exception:
pass
raise
def _observe_available_issue_refresh(task: asyncio.Task) -> None:
@@ -1378,8 +1517,43 @@ def _observe_available_issue_refresh(task: asyncio.Task) -> None:
)
async def _start_available_issue_refresh() -> bool:
"""Atomically start this worker's refresh when the shared lease is available."""
global _available_issue_snapshot_task
async with _available_issue_snapshot_lock:
if (
_available_issue_snapshot_task is not None
and not _available_issue_snapshot_task.done()
):
return True
lease_owner = await asyncio.to_thread(
_available_issue_snapshot_store.try_acquire_refresh,
lease_seconds=AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS,
)
if lease_owner is None:
return False
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot(lease_owner)
)
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
return True
async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
global _available_issue_snapshot_task
global _available_issue_snapshot_value, _available_issue_snapshot_created_at
shared = await asyncio.to_thread(_available_issue_snapshot_store.load)
wall_now = time.time()
if shared.items is not None:
_available_issue_snapshot_value = shared.items
if (
shared.created_at is not None
and wall_now - shared.created_at < AVAILABLE_ISSUE_SNAPSHOT_FRESHNESS_SECONDS
):
_available_issue_snapshot_created_at = time.monotonic()
return shared.items, False, False, False
if shared.retry_at is not None and wall_now < shared.retry_at:
return shared.items, True, False, True
now = time.monotonic()
if (
_available_issue_snapshot_value is not None
@@ -1394,13 +1568,18 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
and now < _available_issue_snapshot_retry_at
):
return _available_issue_snapshot_value, True, False, True
if _available_issue_snapshot_task is None or _available_issue_snapshot_task.done():
_available_issue_snapshot_task = asyncio.create_task(
_refresh_available_issue_snapshot()
)
_available_issue_snapshot_task.add_done_callback(_observe_available_issue_refresh)
local_refresh = await _start_available_issue_refresh()
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True, True, False
if not local_refresh:
for _ in range(50):
await asyncio.sleep(0.02)
shared = await asyncio.to_thread(_available_issue_snapshot_store.load)
if shared.items is not None:
return shared.items, False, False, False
if not shared.refreshing:
break
raise RuntimeError("available issue catalog refresh is owned by another worker")
try:
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
except Exception:
@@ -1530,7 +1709,7 @@ def _record_live_section_failure(section: str) -> None:
LIVE_SNAPSHOT_RETRY_BASE_SECONDS
* (2 ** (_live_section_failure_count[section] - 1)),
)
_live_section_retry_at[section] = time.monotonic() + delay
_live_section_retry_at[section] = _live_snapshot_clock() + delay
def _due_live_sections(now: float) -> set[str]:
@@ -1545,6 +1724,23 @@ def _due_live_sections(now: float) -> set[str]:
return due
def _apply_shared_live_state(state: LiveSnapshotState) -> None:
"""Replace this worker's fast local view with one coherent SQLite read."""
global _live_snapshot_value, _live_snapshot_created_at
global _live_section_created_at, _live_section_failure_count, _live_section_retry_at
global _live_section_revisions, _live_revision_generation
global _live_snapshot_refreshing_sections
_live_snapshot_value = state.value
_live_section_created_at = state.created_at
_live_section_failure_count = state.failure_count
_live_section_retry_at = state.retry_at
_live_section_revisions = state.revisions
_live_revision_generation = state.generation
_live_snapshot_refreshing_sections = state.refreshing_sections
successful = [value for value in state.created_at.values() if value is not None]
_live_snapshot_created_at = max(successful) if successful else None
def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
if previous is None:
return refreshed
@@ -1567,15 +1763,32 @@ def _merge_live_snapshot(previous: dict | None, refreshed: dict) -> dict:
return merged
async def _refresh_live_snapshot(sections: set[str]) -> dict:
async def _refresh_live_snapshot(sections: set[str], lease_owner: str) -> dict:
global _live_snapshot_value, _live_snapshot_created_at
try:
refreshed = await _build_live_snapshot_before_deadline(sections)
except asyncio.CancelledError:
try:
await asyncio.shield(
asyncio.to_thread(_live_snapshot_store.release_refresh, lease_owner)
)
finally:
raise
except Exception:
for section in sections:
_record_live_section_failure(section)
try:
state = await asyncio.to_thread(
_live_snapshot_store.fail_refresh,
lease_owner,
failure_count=_live_section_failure_count,
retry_at=_live_section_retry_at,
)
_apply_shared_live_state(state)
except RefreshLeaseLost:
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
raise
now = time.monotonic()
now = _live_snapshot_clock()
refreshed_states = refreshed.get("sections") or {}
for section in sections:
if refreshed_states.get(section) == "fresh":
@@ -1587,6 +1800,7 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
previous = _live_snapshot_value
result = _merge_live_snapshot(previous, refreshed)
result = _without_read_notifications(result)
changed_sections = set()
for section in sections:
if section not in refreshed_states:
continue
@@ -1596,11 +1810,24 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
"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
return result
changed_sections.add(section)
try:
state = await asyncio.to_thread(
_live_snapshot_store.publish_refresh,
lease_owner,
value=result,
created_at=_live_section_created_at,
failure_count=_live_section_failure_count,
retry_at=_live_section_retry_at,
changed_sections=changed_sections,
)
except RefreshLeaseLost:
state = await asyncio.to_thread(_live_snapshot_store.load)
if state.value is None:
raise
_apply_shared_live_state(state)
assert state.value is not None
return state.value
def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
@@ -1609,12 +1836,22 @@ def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
task.exception()
def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
def _start_live_snapshot_refresh(
sections: set[str], lease_owner: str | None = None
) -> asyncio.Task:
global _live_snapshot_task
global _live_snapshot_refreshing_sections
if _live_snapshot_task is None or _live_snapshot_task.done():
if lease_owner is None:
lease_owner = _live_snapshot_store.try_acquire_refresh(
sections, lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0
)
if lease_owner is None:
raise RuntimeError("live snapshot refresh lease is already held")
_live_snapshot_refreshing_sections = set(sections)
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot(sections))
_live_snapshot_task = asyncio.create_task(
_refresh_live_snapshot(sections, lease_owner)
)
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
return _live_snapshot_task
@@ -1627,7 +1864,7 @@ def _live_snapshot_payload(
known_revisions: dict[str, str | None] | None = None,
) -> dict:
payload = dict(value)
now = time.monotonic()
now = _live_snapshot_clock()
section_freshness = {}
for section in LIVE_SNAPSHOT_SECTIONS:
created_at = _live_section_created_at[section]
@@ -1671,23 +1908,30 @@ def _live_snapshot_payload(
return payload
def _remove_notification_from_live_snapshot(thread_id: int) -> None:
async def _remove_notifications_from_live_snapshot(thread_ids: list[int]) -> None:
global _live_snapshot_value, _read_notification_ids
_read_notification_ids = _read_notification_ids | {thread_id}
if _live_snapshot_value is None:
read_ids = set(thread_ids)
if not read_ids:
return
retained_notifications = _live_snapshot_value.get("notifications")
if not isinstance(retained_notifications, list):
return
updated = dict(_live_snapshot_value)
updated["notifications"] = [
notification
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
_read_notification_ids = _read_notification_ids | read_ids
if _live_snapshot_value is not None:
retained_notifications = _live_snapshot_value.get("notifications")
if isinstance(retained_notifications, list):
updated = dict(_live_snapshot_value)
updated["notifications"] = [
notification
for notification in retained_notifications
if not isinstance(notification, dict)
or notification.get("id") not in read_ids
]
if updated["notifications"] != retained_notifications:
_live_section_revisions["notifications"] += 1
_live_snapshot_value = updated
try:
await asyncio.to_thread(_live_snapshot_store.remove_notifications, read_ids)
except (OSError, sqlite3.Error):
# The upstream mutation already succeeded; keep process-local filtering.
pass
def _without_read_notifications(snapshot: dict) -> dict:
@@ -1711,8 +1955,7 @@ def _without_read_notifications(snapshot: dict) -> dict:
return updated
@app.get("/api/v1/live")
async def live_snapshot(
async def _live_snapshot_response(
context_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
@@ -1730,12 +1973,10 @@ async def live_snapshot(
"events": events_revision,
"notifications": notifications_revision,
}
now = time.monotonic()
_apply_shared_live_state(await asyncio.to_thread(_live_snapshot_store.load))
now = _live_snapshot_clock()
due_sections = _due_live_sections(now)
if (
_live_snapshot_value is not None
and not due_sections
):
if _live_snapshot_value is not None and not due_sections:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value,
@@ -1747,7 +1988,77 @@ async def live_snapshot(
known_revisions=known_revisions,
)
)
task = _start_live_snapshot_refresh(due_sections)
if not due_sections:
retries = [
retry_at - now
for retry_at in _live_section_retry_at.values()
if retry_at is not None and retry_at > now
]
return JSONResponse(
{"error": "Gitea live snapshot is temporarily unavailable"},
status_code=503,
headers={"Retry-After": str(max(1, math.ceil(min(retries, default=1.0))))},
)
task = _live_snapshot_task
if task is None or task.done():
lease_owner = await asyncio.to_thread(
_live_snapshot_store.try_acquire_refresh,
due_sections,
lease_seconds=CONTEXT_TIMEOUT_SECONDS + 1.0,
)
if lease_owner is not None:
latest = await asyncio.to_thread(_live_snapshot_store.load)
_apply_shared_live_state(latest)
due_sections = _due_live_sections(_live_snapshot_clock())
if not due_sections:
await asyncio.to_thread(
_live_snapshot_store.release_refresh, lease_owner
)
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value,
stale=any(
state != "fresh"
for state in (_live_snapshot_value or {}).get("sections", {}).values()
),
revalidating=False,
known_revisions=known_revisions,
)
)
task = _start_live_snapshot_refresh(due_sections, lease_owner)
else:
shared = await asyncio.to_thread(_live_snapshot_store.load)
_apply_shared_live_state(shared)
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value,
stale=bool(_due_live_sections(_live_snapshot_clock())),
revalidating=bool(shared.refreshing_sections),
known_revisions=known_revisions,
)
)
deadline = time.perf_counter() + CONTEXT_TIMEOUT_SECONDS
while time.perf_counter() < deadline:
await asyncio.sleep(0.01)
shared = await asyncio.to_thread(_live_snapshot_store.load)
if shared.value is not None:
_apply_shared_live_state(shared)
return JSONResponse(
_live_snapshot_payload(
shared.value,
stale=bool(_due_live_sections(_live_snapshot_clock())),
revalidating=bool(shared.refreshing_sections),
known_revisions=known_revisions,
)
)
return JSONResponse(
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
status_code=503,
headers={"Retry-After": str(max(1, math.ceil(CONTEXT_TIMEOUT_SECONDS)))},
)
assert task is not None
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
@@ -1780,6 +2091,32 @@ async def live_snapshot(
)
@app.get("/api/v1/live")
async def live_snapshot(
context_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
events_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
notifications_revision: str | None = Query(
default=None, max_length=64, pattern=r"^[0-9a-f]{16}\.[0-9]{1,20}$"
),
) -> JSONResponse:
try:
return await _live_snapshot_response(
context_revision=context_revision,
events_revision=events_revision,
notifications_revision=notifications_revision,
)
except (OSError, sqlite3.Error):
return JSONResponse(
{"error": "Gitea live snapshot state is temporarily unavailable"},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/events")
async def event_stream():
@@ -1822,7 +2159,6 @@ async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
)
except Exception:
return thread_id, False
_remove_notification_from_live_snapshot(thread_id)
return thread_id, True
@@ -1852,6 +2188,9 @@ async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
for thread_id, succeeded in [task.result()]
if succeeded
}
await _remove_notifications_from_live_snapshot(
[thread_id for thread_id in thread_ids if thread_id in succeeded_ids]
)
failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids]
return JSONResponse(
{
@@ -1952,7 +2291,7 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
{"error": "The update could not be marked read. Please retry."},
status_code=503,
)
_remove_notification_from_live_snapshot(thread_id)
await _remove_notifications_from_live_snapshot([thread_id])
return JSONResponse({"id": thread_id, "status": "read"})
@@ -1988,6 +2327,7 @@ async def reply_to_notification(
async def claim_available_issue(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
global _available_issue_snapshot_value
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
@@ -2005,12 +2345,16 @@ async def claim_available_issue(
status_code=503,
headers={"Retry-After": "1"},
)
if _available_issue_snapshot_value is not None:
_available_issue_snapshot_value[:] = [
item for item in _available_issue_snapshot_value
if not (
item.get("repository") == repository and item.get("number") == number
)
retained = _available_issue_snapshot_value
shared = await asyncio.to_thread(
_available_issue_snapshot_store.remove_claimed, repository, number
)
if shared.items is not None:
_available_issue_snapshot_value = shared.items
elif retained is not None:
_available_issue_snapshot_value = [
item for item in retained
if item.get("repository") != repository or item.get("number") != number
]
return JSONResponse(result)
@@ -2035,6 +2379,7 @@ async def release_assigned_issue(
headers={"Retry-After": "1"},
)
if result.get("available"):
await asyncio.to_thread(_available_issue_snapshot_store.invalidate)
_available_issue_snapshot_value = None
_available_issue_snapshot_created_at = None
return JSONResponse(result)
@@ -2066,6 +2411,28 @@ async def issue_handoff_candidates(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/mention-candidates")
async def repository_mention_candidates(
owner: str,
repo: str,
q: str = Query(min_length=2, max_length=39, pattern=r"^[A-Za-z0-9_.-]+$"),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.mention_candidates(
f"{owner}/{repo}", q.casefold(), limit=8
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{"error": "Teammate suggestions are temporarily unavailable."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
async def handoff_assigned_issue(
handoff: IssueHandoff,
@@ -2403,6 +2770,48 @@ async def comment_on_assigned_issue(
return JSONResponse(result, status_code=201)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
async def attach_to_assigned_issue(
attachment: IssueAttachment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
try:
content = attachment.content()
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
try:
result = await asyncio.wait_for(
gitea_proxy.upload_assigned_issue_attachment(
repository,
number,
attachment.filename,
attachment.content_type,
content,
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError as exc:
raise HTTPException(status_code=404, detail="Assigned issue not found") from exc
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The screenshot could not be uploaded. Your draft is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
safe_name = (
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
.replace("\r", " ").replace("\n", " ")
)
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
result["markdown"] = f"![{safe_name}](<{safe_url}>)"
return JSONResponse(result, status_code=201)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close")
async def close_assigned_issue(
request: Request,
+8
View File
@@ -5,6 +5,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
@@ -13,6 +14,13 @@ def request_body_limit(method: str, path: str) -> int | None:
normalized_method = method.upper()
if normalized_method == "POST" and path == "/api/v1/session":
return SESSION_BODY_LIMIT
if (
normalized_method == "POST"
and path.startswith("/api/v1/repos/")
and "/issues/" in path
and path.endswith("/attachments")
):
return ISSUE_ATTACHMENT_BODY_LIMIT
if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
return API_MUTATION_BODY_LIMIT
return None
+59 -3
View File
@@ -58,7 +58,8 @@ class SessionStore:
expires_at INTEGER NOT NULL,
management_id TEXT,
device_label TEXT,
created_at INTEGER
created_at INTEGER,
last_active_at INTEGER
)
"""
)
@@ -84,6 +85,7 @@ class SessionStore:
"management_id": "TEXT",
"device_label": "TEXT",
"created_at": "INTEGER",
"last_active_at": "INTEGER",
}
for name, column_type in additions.items():
if name not in columns:
@@ -102,6 +104,10 @@ class SessionStore:
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"UPDATE active_sessions SET last_active_at = ? WHERE last_active_at IS NULL",
(int(self.clock()),),
)
connection.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
"ON active_sessions(management_id)"
@@ -122,14 +128,15 @@ class SessionStore:
)
connection.execute(
"INSERT INTO active_sessions("
"session_hash, expires_at, management_id, device_label, created_at"
") VALUES (?, ?, ?, ?, ?)",
"session_hash, expires_at, management_id, device_label, created_at, last_active_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._digest(session_id),
expires_at,
secrets.token_urlsafe(18),
label,
now,
now,
),
)
except (OSError, sqlite3.Error) as exc:
@@ -147,6 +154,55 @@ class SessionStore:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
return row is not None and row[0] == expires_at and expires_at > now
def status(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> str:
now = int(self.clock())
query = (
"SELECT expires_at, last_active_at FROM active_sessions "
"WHERE session_hash = ?"
)
parameters = (self._digest(session_id),)
try:
try:
with self._connect() as connection:
row = connection.execute(query, parameters).fetchone()
except sqlite3.OperationalError as exc:
if "no such column: last_active_at" not in str(exc):
raise
with self._connect(initialize=True) as connection:
row = connection.execute(query, parameters).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] != expires_at or expires_at <= now:
return "revoked"
if row[1] + max(1, idle_timeout_seconds) <= now:
return "idle"
return "active"
def touch(
self, session_id: str, expires_at: int, *, idle_timeout_seconds: int
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE active_sessions SET last_active_at = ? "
"WHERE session_hash = ? AND expires_at = ? AND expires_at > ? "
"AND last_active_at + ? > ?",
(
now,
self._digest(session_id),
expires_at,
now,
max(1, idle_timeout_seconds),
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke(self, session_id: str) -> None:
try:
with self._connect(initialize=True) as connection:
+67 -17
View File
@@ -37,10 +37,17 @@ class TodayStore:
CREATE TABLE IF NOT EXISTS today_plans (
login TEXT PRIMARY KEY,
revision INTEGER NOT NULL,
ids TEXT NOT NULL
ids TEXT NOT NULL,
capacity_minutes INTEGER,
estimates TEXT NOT NULL DEFAULT '{}'
)
"""
)
plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")}
if "capacity_minutes" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
if "estimates" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_operations (
@@ -88,13 +95,20 @@ class TodayStore:
@staticmethod
def _snapshot(row) -> dict:
if row is None:
return {"revision": 0, "ids": []}
return {"revision": int(row[0]), "ids": json.loads(row[1])}
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
ids = json.loads(row[1])
estimates = json.loads(row[3] or "{}")
return {
"revision": int(row[0]),
"ids": ids,
"capacity_minutes": row[2],
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
}
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, ids FROM today_plans WHERE login = ?",
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
@@ -119,7 +133,7 @@ class TodayStore:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
@@ -130,6 +144,7 @@ class TodayStore:
return snapshot
ids = list(snapshot["ids"])
estimates = dict(snapshot["estimates"])
changed = False
if action == "add":
if item_id not in ids:
@@ -140,6 +155,7 @@ class TodayStore:
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
estimates.pop(item_id, None)
changed = True
else:
try:
@@ -152,18 +168,25 @@ class TodayStore:
changed = True
revision = snapshot["revision"] + (1 if changed else 0)
serialized_ids = json.dumps(ids, separators=(",", ":"))
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
(login, revision, json.dumps(ids, separators=(",", ":"))),
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
)
elif changed:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, json.dumps(ids, separators=(",", ":")), login),
"UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?",
(revision, serialized_ids, serialized_estimates, login),
)
self._record_operation(connection, login, operation_id)
return {"revision": revision, "ids": ids}
return {
"revision": revision,
"ids": ids,
"capacity_minutes": snapshot["capacity_minutes"],
"estimates": estimates,
}
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply an ordered batch with one lock and receipt per operation."""
@@ -171,10 +194,12 @@ class TodayStore:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
ids = list(snapshot["ids"])
capacity_minutes = snapshot["capacity_minutes"]
estimates = dict(snapshot["estimates"])
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
@@ -187,7 +212,7 @@ class TodayStore:
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"}:
if action not in {"add", "remove", "move", "configure"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
@@ -220,8 +245,9 @@ class TodayStore:
elif action == "remove":
if item_id in ids:
ids.remove(item_id)
estimates.pop(item_id, None)
changed = True
else:
elif action == "move":
try:
index = ids.index(item_id)
except ValueError:
@@ -230,24 +256,48 @@ class TodayStore:
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
else:
proposed_capacity = operation.get("capacity_minutes")
if proposed_capacity is not None and (
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
or proposed_capacity < 15 or proposed_capacity > 1440
):
raise ValueError("capacity_minutes must be between 15 and 1440")
proposed_estimates = operation.get("estimates", {})
if not isinstance(proposed_estimates, dict):
raise ValueError("estimates must be an object")
normalized_estimates = {}
for estimate_id, minutes in proposed_estimates.items():
if estimate_id not in ids:
continue
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
raise ValueError("estimate minutes must be between 5 and 1440")
normalized_estimates[estimate_id] = minutes
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
capacity_minutes = proposed_capacity
estimates = normalized_estimates
revision += 1 if changed else 0
self._record_operation(connection, login, operation_id)
accepted.append(operation_id)
serialized = json.dumps(ids, separators=(",", ":"))
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
(login, revision, serialized),
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
(login, revision, serialized, capacity_minutes, serialized_estimates),
)
elif accepted:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
(revision, serialized, login),
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
(revision, serialized, capacity_minutes, serialized_estimates, login),
)
return {
"revision": revision,
"ids": ids,
"capacity_minutes": capacity_minutes,
"estimates": estimates,
"accepted_operation_ids": accepted,
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
+14 -4
View File
@@ -1,9 +1,10 @@
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, Response
from src.frontend_bundle import build_frontend
from src.compression import _quality
router = APIRouter()
DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html"
@@ -51,11 +52,20 @@ async def service_worker() -> Response:
@router.get("/runtime-{digest}.js")
async def runtime_bundle(digest: str) -> Response:
async def runtime_bundle(digest: str, request: Request) -> Response:
if digest != FRONTEND_BUILD.runtime_digest:
raise HTTPException(status_code=404, detail="Runtime revision not found")
accepts_gzip = _quality(request.headers.get("Accept-Encoding", ""), "gzip") > 0
return Response(
FRONTEND_BUILD.runtime_bytes,
(
FRONTEND_BUILD.runtime_gzip_bytes
if accepts_gzip
else FRONTEND_BUILD.runtime_bytes
),
media_type="application/javascript",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Vary": "Accept-Encoding",
**({"Content-Encoding": "gzip"} if accepts_gzip else {}),
},
)
+125
View File
@@ -77,6 +77,85 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
assert output["remaining"] == []
def test_authored_outbox_persists_and_delivers_complete_pull_review_payload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{ calls.push({{url, options}}); return {{id:44}}; }},
}});
let queued;
try {{
queued = outbox.enqueue({{
kind:'pull-review', repository:'stackchain/web', number:8, operationId:'review-op',
body:'Looks good', decision:'approve', expectedHeadSha:'abc123',
comments:[{{path:'app.js',body:'Nice',new_position:4}}],
draftKey:'stackchain.review-draft.v1:stackchain/web#8@abc123',
progressKey:'stackchain.review-progress.v1:stackchain/web#8@abc123',
draftFingerprint:'draft', progressFingerprint:'progress',
}});
}} catch (error) {{
process.stdout.write(JSON.stringify({{error:error.message}}));
}}
if (queued) {{
storage.setItem(queued.draftKey, 'draft'); storage.setItem(queued.progressKey, 'progress');
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued, calls:calls.map(call=>({{url:call.url,key:call.options.headers['Idempotency-Key'],body:JSON.parse(call.options.body)}})),
result, remaining:outbox.list(), draft:storage.getItem(queued.draftKey), progress:storage.getItem(queued.progressKey),
}})));
}}
"""
output = run_node(script)
assert "error" not in output, output.get("error")
assert output["queued"]["kind"] == "pull-review"
assert output["queued"]["comments"] == [{"path": "app.js", "body": "Nice", "new_position": 4}]
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/web/pulls/8/review",
"key": "review-op",
"body": {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
},
}]
assert output["remaining"] == []
assert output["draft"] is None
assert output["progress"] is None
def test_authored_outbox_persists_and_delivers_idempotent_issue_closure():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map(); const calls = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const outbox = createAuthoredOutbox({{
storage, getOwnerLogin:()=> 'timmy',
fetchJson: async (url, options) => {{ calls.push({{url,options}}); return {{number:27,state:'closed'}}; }},
}});
const queued = outbox.enqueue({{
kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'
}});
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{
queued,calls,result,remaining:outbox.list()
}})));
"""
output = run_node(script)
assert output["queued"]["kind"] == "issue-close"
assert output["calls"] == [{
"url": "api/v1/repos/stackchain/dashboard/issues/27/close",
"options": {
"method": "PATCH",
"headers": {"Accept": "application/json", "Idempotency-Key": "close-op"},
},
}]
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["remaining"] == []
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -183,6 +262,52 @@ process.stdout.write(JSON.stringify({{first,second,items:outbox.list()}}));
assert len(output["items"]) == 1
def test_authored_outbox_deduplicates_pull_review_for_same_head_after_reload():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();let sequence=0;
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const make=()=>createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=> 'op-' + (++sequence)}});
const first=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
const second=make().enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'Ready'}});
process.stdout.write(JSON.stringify({{first,second,items:make().list()}}));
"""
output = run_node(script)
assert output["first"] == output["second"]
assert len(output["items"]) == 1
def test_authored_outbox_rejects_changed_feedback_while_same_head_is_queued():
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'}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'approve',body:'First'}});
let error='';try{{outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',decision:'request_changes',body:'Changed'}});}}catch(e){{error=e.message;}}
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
"""
output = run_node(script)
assert output["error"] == "A review for this saved head is already queued. Open Drafts to inspect or discard it first."
assert len(output["items"]) == 1
def test_confirmed_review_keeps_draft_edits_made_after_it_was_queued():
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),removeItem:k=>values.delete(k)}};
const draftKey='draft-key';const progressKey='progress-key';storage.setItem(draftKey,'old-draft');storage.setItem(progressKey,'old-progress');
const outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',fetchJson:async()=>({{id:1}})}});
outbox.enqueue({{kind:'pull-review',repository:'o/r',number:7,expectedHeadSha:'abc',body:'Queued',draftKey,progressKey,draftFingerprint:'old-draft',progressFingerprint:'old-progress'}});
storage.setItem(draftKey,'newer-draft');storage.setItem(progressKey,'newer-progress');
outbox.flush('timmy').then(()=>process.stdout.write(JSON.stringify({{draft:storage.getItem(draftKey),progress:storage.getItem(progressKey)}})));
"""
output = run_node(script)
assert output == {"draft": "newer-draft", "progress": "newer-progress"}
def test_authored_outbox_mirrors_to_background_sync_and_reconciles_worker_results():
script = f"""
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
@@ -0,0 +1,101 @@
import threading
import os
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
def test_independent_workers_allow_only_one_catalog_refresh(tmp_path):
path = tmp_path / "available.sqlite3"
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
barrier = threading.Barrier(2)
results = []
def acquire(store):
barrier.wait()
results.append(store.try_acquire_refresh(lease_seconds=5))
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sum(owner is not None for owner in results) == 1
def test_published_catalog_and_metadata_are_visible_to_another_worker(tmp_path):
path = tmp_path / "available.sqlite3"
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = writer.try_acquire_refresh(lease_seconds=5)
writer.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
state = reader.load()
assert state.items == [{"repository": "stackchain/api", "number": 7}]
assert state.created_at == 100.0
assert state.retry_at is None
assert state.refreshing is False
def test_failed_refresh_backoff_is_shared_and_lease_is_released(tmp_path):
path = tmp_path / "available.sqlite3"
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = writer.try_acquire_refresh(lease_seconds=5)
writer.fail_refresh(owner, retry_at=105.0)
state = reader.load()
assert state.retry_at == 105.0
assert state.refreshing is False
assert reader.try_acquire_refresh(lease_seconds=5) is not None
def test_confirmed_claim_is_removed_for_every_worker(tmp_path):
path = tmp_path / "available.sqlite3"
writer = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
reader = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = writer.try_acquire_refresh(lease_seconds=5)
writer.publish(owner, items=[
{"repository": "stackchain/api", "number": 7},
{"repository": "stackchain/web", "number": 8},
])
writer.remove_claimed("stackchain/api", 7)
assert reader.load().items == [{"repository": "stackchain/web", "number": 8}]
def test_invalidation_clears_catalog_and_claim_filters(tmp_path):
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
owner = store.try_acquire_refresh(lease_seconds=5)
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
store.remove_claimed("stackchain/api", 7)
store.invalidate()
owner = store.try_acquire_refresh(lease_seconds=5)
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
assert store.load().items == [{"repository": "stackchain/api", "number": 7}]
def test_catalog_store_is_private(tmp_path):
path = tmp_path / "state" / "available.sqlite3"
AvailableIssueSnapshotStore(path)
assert os.stat(path.parent).st_mode & 0o777 == 0o700
assert os.stat(path).st_mode & 0o777 == 0o600
def test_cancelled_refresh_releases_its_lease_for_immediate_takeover(tmp_path):
path = tmp_path / "available.sqlite3"
first = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
second = AvailableIssueSnapshotStore(path, clock=lambda: 100.0)
owner = first.try_acquire_refresh(lease_seconds=30)
first.release_refresh(owner)
assert second.try_acquire_refresh(lease_seconds=30) is not None
+192
View File
@@ -64,6 +64,71 @@ const fetchJson = async (url, options = {{}}) => {{
}
def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let item = {{
id:'capture-image',operationId:'capture-image',ownerLogin:'timmy',status:'queued',
repository:'stackchain/dashboard',title:'Broken mobile layout',body:'At 320px',labelIds:[],
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}},
}};
const state = {{calls:[],completed:0,released:0}};
const store = {{
claimNext:async()=>item ? {{...item}} : null,
update:async(_id,transform)=>{{item=transform(item);}},
complete:async()=>{{state.completed+=1;item=null;}},
release:async()=>{{state.released+=1;item={{...item,status:'queued'}};}},
fail:async()=>{{}},countBlocked:async()=>0,
}};
let uploadAttempts=0;
const fetchJson=async(url,options={{}})=>{{
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
if(url==='api/v1/background-identity')return{{login:'timmy'}};
if(url.endsWith('/issues'))return{{repository:'stackchain/dashboard',number:469,title:'Broken mobile layout'}};
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('Upload unavailable');error.status=503;throw error;}}
if(url.endsWith('/attachments'))return{{markdown:'![phone.png](<https://forge.example/a/phone.png>)'}};
if(url.endsWith('/comments'))return{{id:91}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
let firstError='';try{{await sync.flush();}}catch(error){{firstError=error.message;}}
const afterFirst={{...item}};
const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,afterFirst,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Upload unavailable"
assert output["afterFirst"]["deliveredIssue"]["number"] == 469
assert [call["url"] for call in output["state"]["calls"]].count(
"api/v1/repos/stackchain/dashboard/issues"
) == 1
assert output["state"]["calls"][-2]["key"] == "capture-image:attachment"
assert output["state"]["calls"][-1]["key"] == "capture-image:attachment-comment"
assert output["state"]["calls"][-1]["body"] == {
"body": "![phone.png](<https://forge.example/a/phone.png>)"
}
assert output["state"]["completed"] == 1
assert output["second"]["confirmed"][0]["number"] == 469
def test_capture_attachment_stage_keys_remain_within_idempotency_limit():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const item={{id:'long',operationId:'x'.repeat(128),ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
const keys=[];let queued=true;
const store={{claimNext:async()=>queued?(queued=false,item):null,update:async()=>{{}},complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
const fetchJson=async(url,options={{}})=>{{if(url==='api/v1/background-identity')return{{login:'timmy'}};if(options.headers?.['Idempotency-Key'])keys.push(options.headers['Idempotency-Key']);if(url.endsWith('/issues'))return{{repository:'o/r',number:1}};if(url.endsWith('/attachments'))return{{markdown:'![a](url)'}};return{{id:1}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(()=>process.stdout.write(JSON.stringify(keys)));
"""
keys = run_node(script)
assert len(keys) == 3
assert len(set(keys)) == 3
assert all(len(key) <= 128 for key in keys)
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -203,6 +268,69 @@ const fetchJson = async (url, options = {{}}) => {{
assert output["result"]["confirmed"] == [{"id": 42}]
def test_closed_app_sync_delivers_pull_review_with_stable_identity_and_full_payload():
authored = {
"id": "review-op", "operationId": "review-op", "ownerLogin": "timmy", "status": "queued",
"kind": "pull-review", "repository": "stackchain/web", "number": 8,
"body": "Looks good", "decision": "approve", "expectedHeadSha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)}; const calls=[];
const store = {{
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/web/pulls/8/review"
assert mutation["options"]["headers"]["Idempotency-Key"] == "review-op"
assert json.loads(mutation["options"]["body"]) == {
"body": "Looks good", "decision": "approve", "expected_head_sha": "abc123",
"comments": [{"path": "app.js", "body": "Nice", "new_position": 4}],
}
assert output["result"]["confirmed"] == [{"id": 45}]
assert output["result"]["receipts"] == [{
"id": "review-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/review/stackchain/web/8",
}]
def test_closed_app_sync_delivers_issue_closure_and_returns_actionable_receipt():
authored = {
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
}
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
let queued = {json.dumps(authored)}; const calls=[];
const store = {{
claimNext:async owner=>queued?.ownerLogin===owner?(queued=null,{json.dumps(authored)}):null,
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{calls.push({{url,options}});return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};}};
createBackgroundIssueSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
"""
output = run_node(script)
mutation = output["calls"][1]
assert mutation["url"] == "api/v1/repos/stackchain/dashboard/issues/27/close"
assert mutation["options"]["method"] == "PATCH"
assert mutation["options"]["headers"] == {
"Accept": "application/json", "Idempotency-Key": "close-op"
}
assert output["result"]["confirmed"] == [{"number": 27, "state": "closed"}]
assert output["result"]["receipts"] == [{
"id": "close-op", "status": "confirmed", "kind": "message",
"route": "#/my-work/issue/stackchain/dashboard/27",
}]
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -226,6 +354,25 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
script = f"""
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{getAll:async()=>[...records.values()].map(value=>({{...value}})),put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id)}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
await store.update('capture',item=>({{...item,deliveredIssue:{{number:7}},attachmentMarkdown:'![a](url)'}}));
const result=await store.upsert({{id:'capture',operationId:'same',ownerLogin:'timmy',status:'queued',attachment:{{filename:'a.png'}}}});
process.stdout.write(JSON.stringify(result));
}})();
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -778,6 +925,51 @@ const transaction=work=>{{const run=tail.then(()=>work({{
assert "error" not in output
def test_resume_after_purge_reopens_background_delivery_with_fresh_identity():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state={{closed:0,identityLookups:0,mutations:0,keys:[]}};
let queued={{
id:'capture-after-login',ownerLogin:'timmy',status:'queued',repository:'stackchain/dashboard',
title:'Recovered capture',body:'Evidence',labelIds:[],operationId:'op-after-login',
}};
const store={{
close:async()=>{{state.closed+=1;}},
claimBatch:async login=>{{
if(queued?.ownerLogin!==login)return [];
const claimed=queued;queued=null;return [claimed];
}},
complete:async()=>{{}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0,
}};
const fetchJson=async(url,options={{}})=>{{
if(url==='api/v1/background-identity'){{state.identityLookups+=1;return {{login:'timmy'}};}}
state.mutations+=1;state.keys.push(options.headers['Idempotency-Key']);
return {{repository:'stackchain/dashboard',number:425,title:'Recovered capture'}};
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson}});
await sync.purge();
const blocked=await sync.flush();
await sync.resume();
const delivered=await sync.flush();
process.stdout.write(JSON.stringify({{state,blocked,delivered}}));
}})();
"""
output = run_node(script)
assert output["state"] == {
"closed": 1,
"identityLookups": 1,
"mutations": 1,
"keys": ["op-after-login"],
}
assert output["blocked"]["login"] == ""
assert output["delivered"]["login"] == "timmy"
assert output["delivered"]["confirmed"] == [
{"repository": "stackchain/dashboard", "number": 425, "title": "Recovered capture"}
]
@pytest.mark.anyio
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
calls = 0
+284
View File
@@ -0,0 +1,284 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_comment_and_next_posts_then_completes_current_today_item_once():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
let finishPost;
const controller = createCommentNext({{
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
queue: () => {{ throw new Error('must not queue'); }},
canQueue: () => false,
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
const first = controller.submit(item, 'Handoff ready');
const second = controller.submit(item, 'Handoff ready');
if (first !== second) throw new Error('submission was not single-flight');
finishPost({{id:7, body:'Handoff ready'}});
(async () => {{
const result = await first;
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 7, "body": "Handoff ready"},
"completed": True,
},
"calls": ["post:429:Handoff ready", "complete:429"],
"busy": False,
}
def test_comment_and_next_advances_after_durable_offline_admission():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = new Error('offline'); retryable.status = 503;
const controller = createCommentNext({{
post: () => Promise.reject(retryable),
canQueue: error => error.status === 503,
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
process.stdout.write(JSON.stringify({{result, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"result": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{
"kind": "pull-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "Please review",
"operationId": "op-12",
},
"complete:12",
],
}
def test_update_reply_and_next_preserves_notification_identity_online_and_offline():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const retryable = Object.assign(new Error('offline'), {{status:503}});
let offline = false;
const controller = createCommentNext({{
queueKind: 'update-reply',
post: (item, body, operationId) => {{
calls.push({{post:[item.notification_id, body, operationId]}});
return offline ? Promise.reject(retryable) : Promise.resolve({{id:17}});
}},
canQueue: () => true,
queue: message => {{ calls.push({{queue:message}}); return Promise.resolve({{durable:true}}); }},
complete: item => {{ calls.push({{complete:item.notification_id}}); return true; }},
}});
(async () => {{
const item = {{kind:'issue', notification_id:91, repository:'stackchain/dashboard', number:8}};
const posted = await controller.submit(item, 'Online reply', 'reply-91-a');
offline = true;
const saved = await controller.submit(item, 'Offline reply', 'reply-91-b');
process.stdout.write(JSON.stringify({{posted, saved, calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"posted": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 17},
"completed": True,
},
"saved": {
"accepted": True,
"delivery": "saved",
"background": False,
"completed": True,
},
"calls": [
{"post": [91, "Online reply", "reply-91-a"]},
{"complete": 91},
{"post": [91, "Offline reply", "reply-91-b"]},
{
"queue": {
"kind": "update-reply",
"notificationId": 91,
"body": "Offline reply",
"operationId": "reply-91-b",
}
},
{"complete": 91},
],
}
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
let operationId = '';
let queued;
const controller = createCommentNext({{
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
canQueue: () => true,
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
complete: () => true,
}});
(async () => {{
const result = await controller.submit(
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
);
process.stdout.write(JSON.stringify({{result, queued}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["result"]["accepted"] is True
assert output["result"]["delivery"] == "saved"
assert output["queued"]["operationId"] == "post-attempt-id"
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const calls = [];
const controller = createCommentNext({{
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
accept: item => calls.push(`clear:${{item.number}}`),
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
}});
(async () => {{
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
process.stdout.write(JSON.stringify(calls));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
script = f"""
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
const failures = [];
const permanent = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
canQueue: () => false,
queue: () => Promise.resolve({{durable:true}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const notDurable = createCommentNext({{
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
canQueue: () => true,
queue: () => Promise.resolve({{durable:false, background:false}}),
complete: () => {{ failures.push('advanced'); return true; }},
}});
const cannotComplete = createCommentNext({{
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
complete: () => false,
}});
(async () => {{
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
}}
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
process.stdout.write(JSON.stringify({{failures, partial}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
assert json.loads(run_node(script)) == {
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
"partial": {
"accepted": True,
"delivery": "posted",
"comment": {"id": 9},
"completed": False,
},
}
def test_today_checkpoint_only_matches_the_current_session_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'issue', repository:'r', number:1}},
{{kind:'pull', repository:'r', number:2}},
];
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
}});
session.start();
process.stdout.write(JSON.stringify({{
current: session.checkpointed(items[0]),
other: session.checkpointed(items[1]),
generic: session.checkpointed(),
}}));
"""
assert json.loads(run_node(script)) == {
"current": True,
"other": False,
"generic": True,
}
@pytest.mark.anyio
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
html = await dashboard()
assert 'id="send-issue-comment-next"' in html
assert 'id="send-pull-comment-next"' in html
assert html.count('>Comment &amp; next</button>') == 2
assert "setCommentNextVisibility('issue')" in html
assert "setCommentNextVisibility('pull')" in html
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.comment-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_current_today_update_offers_reply_and_next_without_marking_read():
html = await dashboard()
assert 'id="send-update-reply-next"' in html
assert '>Reply &amp; next</button>' in html
assert "setUpdateReplyNextVisibility();" in html
assert "const updateReplyNext = createCommentNext({" in html
assert "notificationReplier.saveDraft(item, '');" in html
assert "successMessage: 'Reply saved. Next Today item opened.'" in html
assert "qs('#mark-update-read-next').click()" not in html
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v81" in worker
+180
View File
@@ -4,6 +4,7 @@ from pathlib import Path
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
def run_node(script: str) -> dict:
@@ -116,6 +117,185 @@ const poller = createContextPoller({{
}
def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 60,
sections: {{
context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [60000]}
def test_context_poller_uses_earliest_healthy_section_freshness_deadline():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
retry_in_seconds: 60,
sections: {{
context: {{ degraded: false, age_seconds: 2 }},
events: {{ degraded: false, age_seconds: 5 }},
notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [3000]}
def test_context_poller_does_not_spin_while_stale_sections_are_revalidating():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const poller = createContextPoller({{
fetchContext: () => Promise.resolve({{
context: {{}}, events: [], notifications: [],
freshness: {{
fresh_for_seconds: 8,
sections: {{
context: {{ degraded: false, revalidating: true, age_seconds: 12 }},
events: {{ degraded: false, revalidating: true, age_seconds: 12 }},
notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }},
}},
}},
}}),
onSnapshot: () => {{}},
onError: error => {{ throw error; }},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {"delays": [8000]}
def test_context_poller_honors_http_retry_after_delay():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
const errors = [];
const unavailable = new Error('HTTP 503');
unavailable.retryAfterMs = 45000;
const poller = createContextPoller({{
fetchContext: () => Promise.reject(unavailable),
onSnapshot: () => {{}},
onError: error => errors.push(error.message),
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
}});
(async () => {{
await poller.start();
process.stdout.write(JSON.stringify({{ delays, errors }}));
}})();
"""
assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]}
def test_retry_after_parser_accepts_bounded_delta_seconds():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
process.stdout.write(JSON.stringify({{
valid: createContextPoller.retryAfterMs('45'),
zero: createContextPoller.retryAfterMs('0'),
invalid: createContextPoller.retryAfterMs('later'),
excessive: createContextPoller.retryAfterMs('999999'),
}}));
"""
assert run_node(script) == {
"valid": 45000,
"zero": None,
"invalid": None,
"excessive": 300000,
}
def test_context_poller_backs_off_transport_failures_and_resets_after_success():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const delays = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: () => {{
calls += 1;
if (calls < 6) return Promise.reject(new TypeError('network unavailable'));
return Promise.resolve({{ context: {{}}, events: [], notifications: [] }});
}},
onSnapshot: () => {{}},
onError: () => {{}},
setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }},
clearTimer: () => {{}},
intervalMs: 8000,
maxBackoffMs: 60000,
}});
(async () => {{
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
await poller.refresh();
process.stdout.write(JSON.stringify({{ delays }}));
}})();
"""
assert run_node(script) == {
"delays": [8000, 16000, 32000, 60000, 60000, 8000]
}
def test_live_fetch_passes_retry_after_to_the_poller_error():
source = DASHBOARD.read_text()
assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source
assert "error.retryAfterMs" in source
def test_live_revision_query_preserves_bounded_opaque_tokens():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
+63 -12
View File
@@ -1,4 +1,5 @@
import asyncio
import sqlite3
import time
from urllib.parse import parse_qs, urlsplit
@@ -245,10 +246,14 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
lookups = 0
class CountingStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
nonlocal lookups
lookups += 1
return original_store.is_active(session_id, expires_at)
return original_store.status(
session_id,
expires_at,
idle_timeout_seconds=idle_timeout_seconds,
)
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: CountingStore())
response = await client.get("/api/v1/session")
@@ -291,6 +296,52 @@ async def test_anonymous_session_status_discloses_no_offline_lease(access_contro
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_idle_session_is_rejected_with_distinct_api_and_page_recovery(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
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"}
)
with sqlite3.connect(main.dashboard_auth._session_store().path) as connection:
connection.execute(
"UPDATE active_sessions SET last_active_at = ?", (int(time.time()) - 900,)
)
api_response = await client.get("/api/v1/session")
page_response = await client.get("/")
assert api_response.status_code == 401
assert api_response.json() == {
"detail": "Authentication required",
"code": "session_idle",
}
assert page_response.status_code == 303
assert page_response.headers["location"] == "login?reason=session-idle"
@pytest.mark.anyio
async def test_activity_heartbeat_extends_the_server_idle_deadline(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "900")
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"}
)
heartbeat = await client.post(
"/api/v1/session/activity",
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
assert heartbeat.status_code == 200
assert heartbeat.json()["active"] is True
assert heartbeat.json()["idle_expires_at"] >= int(time.time()) + 899
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
@@ -615,8 +666,8 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
grant = await fresh_grant(client, "revoke_all_sessions", "all")
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
def consume_step_up(self, grant, session_id, *, action, target):
return True
@@ -693,9 +744,9 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
)
class SlowStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.15)
return True
return "active"
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
private_request = asyncio.create_task(client.get("/api/v1/session"))
@@ -847,9 +898,9 @@ async def test_single_session_revocation_does_not_block_the_event_loop(
csrf = client.cookies["stackchain_csrf"]
class SlowStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
time.sleep(0.02)
return True
return "active"
def revoke(self, session_id):
time.sleep(0.15)
@@ -893,7 +944,7 @@ async def test_session_registry_read_failure_fails_closed_before_gitea(
)
class BrokenStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
@@ -938,8 +989,8 @@ async def test_logout_registry_failure_does_not_claim_revocation(access_control,
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def status(self, session_id, expires_at, *, idle_timeout_seconds):
return "active"
def revoke(self, session_id):
raise SessionStoreError("database path and secret details")
@@ -966,7 +1017,7 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo
)
class BrokenStore:
def is_active(self, session_id, expires_at):
def status(self, session_id, expires_at, *, idle_timeout_seconds):
raise SessionStoreError("public routes must not read the registry")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
+57 -1
View File
@@ -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: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], activityListeners: {{}}, now: 100000, responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -57,6 +57,8 @@ const boundary = createSessionBoundary({{
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
addActivityListener: (type, listener) => {{ state.activityListeners[type] = listener; }},
now: () => state.now,
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
@@ -232,6 +234,29 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_user_activity_heartbeat_is_throttled_across_pointer_keyboard_and_touch_events():
result = run_session_scenario(
"""
boundary.startActivityHeartbeat();
await state.activityListeners.pointerdown();
await state.activityListeners.keydown();
state.now += 60000;
await state.activityListeners.touchstart();
process.stdout.write(JSON.stringify(state));
"""
)
assert [request["url"] for request in result["requests"]] == [
"/dashboard/api/v1/session/activity",
"/dashboard/api/v1/session/activity",
]
assert all(request["method"] == "POST" for request in result["requests"])
assert all(
request["headers"]["x-csrf-token"] == "csrf-proof"
for request in result["requests"]
)
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
@@ -350,6 +375,24 @@ process.stdout.write(JSON.stringify(state));
assert result["replacedAfterDeletion"] is True
def test_idle_response_locks_without_clearing_private_queued_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
state.responsePayload = {detail:'Authentication required', code:'session_idle'};
await boundary.fetch('/dashboard/api/v1/live');
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["workerMessages"] == []
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_revocation_message_clears_window_storage_before_login():
result = run_session_scenario(
"""
@@ -363,6 +406,19 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_idle_message_locks_without_clearing_window_storage():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-idle'}});
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-idle"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
+44
View File
@@ -121,6 +121,48 @@ process.stdout.write(JSON.stringify(drafts));
assert output[1]["label"] == "Queued issue"
def test_draft_inbox_exposes_queued_review_with_review_route_and_actionable_state():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'review-1',kind:'pull-review',repository:'stackchain/web',number:8,body:'Looks good',
decision:'approve',expectedHeadSha:'abc',comments:[{{path:'app.js',body:'Nice',new_position:4}}],ownerLogin:'timmy',status:'queued',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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["label"] == "Queued review"
assert output["outbox_kind"] == "pull-review"
assert output["title"] == "stackchain/web#8"
assert output["route"] == {"kind": "review", "repository": "stackchain/web", "number": 8}
assert output["copy_text"] == "Looks good\n\nInline feedback\napp.js · new line 4: Nice"
assert output["quarantined"] is False
def test_draft_inbox_exposes_queued_issue_closure_for_retry_or_discard():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({{version:2,items:[{{
id:'close-1',kind:'issue-close',repository:'stackchain/dashboard',number:27,body:'',
ownerLogin:'timmy',status:'attention',error:'Closure rejected',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 item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
process.stdout.write(JSON.stringify(item));
"""
output = run_node(script)
assert output["label"] == "Issue closure needs attention"
assert output["outbox_kind"] == "issue-close"
assert output["title"] == "stackchain/dashboard#27"
assert output["preview"] == "Closure rejected"
assert output["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
assert output["quarantined"] is False
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
script = f"""
const createDraftInbox = require({json.dumps(str(DRAFTS))});
@@ -197,6 +239,8 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
assert "const closureOutbox = item.outbox_kind === 'issue-close';" in html
assert "closureOutbox ? 'Open issue' : 'Open message'" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html
+44 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import httpx
import pytest
from starlette.requests import Request
from src import main
from src.frontend_bundle import build_frontend
@@ -21,6 +22,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_name == second.runtime_name
assert first.runtime_bytes == second.runtime_bytes
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 1
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
@@ -52,11 +55,46 @@ async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert int(response.headers["content-length"]) <= 100 * 1024
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_gzip_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
async def test_runtime_route_returns_the_precomputed_gzip_representation():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"gzip")]}
)
response = await runtime_bundle(build.runtime_digest, request)
assert response.body == build.runtime_gzip_bytes
assert response.headers["content-encoding"] == "gzip"
assert response.headers["content-length"] == str(len(build.runtime_gzip_bytes))
assert response.headers["vary"] == "Accept-Encoding"
@pytest.mark.anyio
async def test_runtime_requests_bypass_dynamic_gzip_compression(monkeypatch):
class UnexpectedDynamicCompression:
def __init__(self, *args, **kwargs):
raise AssertionError("immutable runtime entered dynamic compression")
monkeypatch.setattr("src.compression.GZipResponder", UnexpectedDynamicCompression)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
@@ -108,9 +146,13 @@ def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules()
@pytest.mark.anyio
async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate():
build = build_frontend(FRONTEND)
request = Request(
{"type": "http", "method": "GET", "path": f"/{build.runtime_name}",
"headers": [(b"accept-encoding", b"identity")]}
)
html = await dashboard()
bundle = await runtime_bundle(build.runtime_digest)
bundle = await runtime_bundle(build.runtime_digest, request)
worker = await service_worker()
assert build.runtime_name in html
+76
View File
@@ -306,3 +306,79 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
main._available_issue_snapshot_task = None
assert calls == ["start", "available scan cancelled", "stop (done)"]
@pytest.mark.anyio
async def test_application_shutdown_drains_authored_mutation_before_transport(monkeypatch):
calls = []
started = asyncio.Event()
release = asyncio.Event()
async def authored_mutation():
started.set()
await release.wait()
calls.append("mutation persisted")
return {"id": 461}
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
calls.append("stop")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
context = main.app.router.lifespan_context(main.app)
await context.__aenter__()
task = asyncio.create_task(authored_mutation())
main._authored_action_operations["shutdown-drain-461"] = (
("comment", "stackchain/dashboard", 461),
task,
0.0,
)
await started.wait()
shutdown = asyncio.create_task(context.__aexit__(None, None, None))
await asyncio.sleep(0)
assert not shutdown.done()
assert calls == ["start"]
release.set()
await shutdown
assert calls == ["start", "mutation persisted", "stop"]
@pytest.mark.anyio
async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation(monkeypatch):
calls = []
started = asyncio.Event()
async def stalled_mutation():
started.set()
try:
await asyncio.Event().wait()
finally:
calls.append("mutation cancelled")
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
async def stop_client():
calls.append("stop")
monkeypatch.setattr(gitea_proxy, "stop_client", stop_client)
context = main.app.router.lifespan_context(main.app)
await context.__aenter__()
task = asyncio.create_task(stalled_mutation())
main._authored_action_operations["stalled-shutdown-461"] = (
("review", "stackchain/dashboard", 461),
task,
0.0,
)
await started.wait()
await asyncio.wait_for(context.__aexit__(None, None, None), timeout=0.2)
assert task.cancelled()
assert main._authored_action_operations == {}
assert calls == ["start", "mutation cancelled", "stop"]
+34 -1
View File
@@ -6,11 +6,18 @@ import httpx
from src import gitea_proxy
from src import main
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
@pytest.fixture(autouse=True)
def reset_available_issue_snapshot():
def reset_available_issue_snapshot(tmp_path, monkeypatch):
monkeypatch.setattr(
main,
"_available_issue_snapshot_store",
AvailableIssueSnapshotStore(tmp_path / "available-issues.sqlite3"),
)
main._available_issue_snapshot_task = None
main._available_issue_snapshot_lock = asyncio.Lock()
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
@@ -19,6 +26,7 @@ def reset_available_issue_snapshot():
if task is not None and not task.done():
task.cancel()
main._available_issue_snapshot_task = None
main._available_issue_snapshot_lock = asyncio.Lock()
main._available_issue_snapshot_value = None
main._available_issue_snapshot_created_at = None
main._available_issue_snapshot_retry_at = None
@@ -207,6 +215,31 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
assert calls == [True]
@pytest.mark.anyio
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
monkeypatch, tmp_path
):
store = AvailableIssueSnapshotStore(tmp_path / "available.sqlite3", clock=lambda: 100.0)
owner = store.try_acquire_refresh(lease_seconds=5)
store.publish(owner, items=[{"repository": "stackchain/api", "number": 7}])
monkeypatch.setattr(main, "_available_issue_snapshot_store", store, raising=False)
monkeypatch.setattr(main.time, "time", lambda: 100.0)
async def must_not_scan():
raise AssertionError("fresh shared catalog should avoid an upstream scan")
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/available-issues?page=1")
assert response.status_code == 200
assert response.json() == {
"items": [{"repository": "stackchain/api", "number": 7}],
"page": 1, "total": 1, "has_more": False,
}
@pytest.mark.anyio
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
calls = 0
+106
View File
@@ -1141,6 +1141,32 @@ async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
]
@pytest.mark.anyio
async def test_gitea_mention_candidates_match_login_or_name_and_are_bounded():
async def handler(request):
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
return httpx.Response(200, json=[
{"login": "alexa", "full_name": "Alexa Dev"},
{"login": "buildbot", "full_name": "Alex Builder"},
{"login": "alexb", "full_name": "Alex Backup"},
{"login": "casey", "full_name": "Casey"},
{"login": "AL", "full_name": "Al"},
{"login": "bad space", "full_name": "Malformed"},
{"full_name": "Missing login"},
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.mention_candidates("stackchain/api", "alex", limit=2)
finally:
await gitea_proxy.stop_client()
assert result == [
{"login": "alexa", "name": "Alexa Dev"},
{"login": "alexb", "name": "Alex Backup"},
]
@pytest.mark.anyio
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
requests = []
@@ -1255,6 +1281,31 @@ async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monk
]
@pytest.mark.anyio
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
calls = []
async def candidates(repository, query, *, limit):
calls.append((repository, query, limit))
return [{"login": "alex", "name": "Alexander"}]
monkeypatch.setattr(main.gitea_proxy, "mention_candidates", candidates, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
too_short = await client.get(
"/api/v1/repos/stackchain/api/mention-candidates?q=a"
)
response = await client.get(
"/api/v1/repos/stackchain/api/mention-candidates?q=AlEx"
)
assert too_short.status_code == 422
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == [{"login": "alex", "name": "Alexander"}]
assert calls == [("stackchain/api", "alex", 8)]
@pytest.mark.anyio
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
async def handoff(_repository, _number, _recipient):
@@ -1543,6 +1594,25 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
async def handler(request):
requests.append((request.method, request.url.path, request.url.query.decode()))
if request.url.path.endswith("/dependencies"):
return httpx.Response(
200,
json=[
{
"number": 3,
"title": "Restore signing service",
"state": "open",
"html_url": "https://forge.example/stackchain/platform/issues/3",
"repository": {"full_name": "stackchain/platform"},
},
{
"number": 2,
"title": "Completed prerequisite",
"state": "closed",
"repository": {"full_name": "stackchain/platform"},
},
],
)
if request.url.path.endswith("/comments"):
return httpx.Response(
200,
@@ -1580,6 +1650,7 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
assert requests == [
("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
("GET", "/api/v1/repos/stackchain/api/issues/7/dependencies", "limit=20"),
]
assert result == {
"repository": "stackchain/api",
@@ -1593,6 +1664,16 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
"url": "https://forge.example/stackchain/api/issues/7",
"labels": ["P1"],
"assignees": ["timmy"],
"dependencies_available": True,
"dependencies": [
{
"repository": "stackchain/platform",
"number": 3,
"title": "Restore signing service",
"state": "open",
"url": "https://forge.example/stackchain/platform/issues/3",
}
],
"comments": [
{
"id": 81,
@@ -1619,6 +1700,31 @@ async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments
}
@pytest.mark.anyio
async def test_gitea_issue_detail_keeps_planning_usable_when_dependencies_are_unavailable():
async def handler(request):
if request.url.path.endswith("/dependencies"):
return httpx.Response(503, json={"message": "upstream timeout"})
if request.url.path.endswith("/comments"):
return httpx.Response(200, json=[])
return httpx.Response(200, json={
"number": 7,
"title": "Fix mobile flow",
"state": "open",
"assignees": [{"login": "timmy"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_detail("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert result["title"] == "Fix mobile flow"
assert result["dependencies_available"] is False
assert result["dependencies"] == []
@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
requests = []
+190
View File
@@ -0,0 +1,190 @@
import json
import re
import subprocess
from pathlib import Path
ATTACHMENT = Path(__file__).parents[1] / "frontend" / "issue-attachment.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
README = Path(__file__).parents[1] / "README.md"
def run_node(script: str) -> str:
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_mobile_attachment_prepares_comment_and_reuses_confirmed_upload():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls = [];
const file = {{name:'checkout.png', type:'image/png', size:8}};
const controller = attachment.create({{
readDataUrl: async selected => {{ calls.push('read:' + selected.name); return 'data:image/png;base64,iVBORw0KGgo='; }},
upload: async payload => {{ calls.push(payload); return {{markdown:'![checkout.png](https://forge.example/a.png)'}}; }},
}});
controller.select(file);
(async()=>{{
const first = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
const second = await controller.prepareComment({{repository:'stackchain/api', number:17}}, 'Layout breaks');
process.stdout.write(JSON.stringify({{first,second,calls,state:controller.state()}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
output = json.loads(run_node(script))
assert output["first"] == (
"Layout breaks\n\n![checkout.png](https://forge.example/a.png)"
)
assert output["second"] == output["first"]
assert output["calls"] == [
"read:checkout.png",
{
"repository": "stackchain/api",
"number": 17,
"filename": "checkout.png",
"content_type": "image/png",
"data": "iVBORw0KGgo=",
},
]
assert output["state"] == {
"name": "checkout.png", "size": 8, "uploaded": True
}
def test_selected_screenshot_serializes_for_durable_issue_capture_without_uploading():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
const calls=[];
const controller=attachment.create({{
readDataUrl:async file=>{{calls.push('read:'+file.name);return 'data:image/webp;base64,UklGRg==';}},
upload:async()=>{{calls.push('upload');}},
}});
controller.select({{name:'phone.webp',type:'image/webp',size:8}});
controller.serialize().then(value=>process.stdout.write(JSON.stringify({{value,calls}})));
"""
output = json.loads(run_node(script))
assert output == {
"value": {
"filename": "phone.webp",
"contentType": "image/webp",
"data": "UklGRg==",
},
"calls": ["read:phone.webp"],
}
def test_serialized_capture_attachment_can_be_restored_or_removed_while_editing():
script = f"""
const attachment=require({json.dumps(str(ATTACHMENT))});
const controller=attachment.create({{readDataUrl:async()=>{{throw new Error('must not reread');}},upload:async()=>{{}}}});
controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0KGgo='}});
(async()=>{{const restored={{state:controller.state(),value:await controller.serialize()}};controller.clear();process.stdout.write(JSON.stringify({{restored,removed:await controller.serialize()}}));}})();
"""
output = json.loads(run_node(script))
assert output["restored"]["state"]["name"] == "saved.png"
assert output["restored"]["value"]["data"] == "iVBORw0KGgo="
assert output["removed"] is None
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
html = INDEX.read_text()
css = CSS.read_text()
assert 'id="issue-attachment"' in html
assert 'type="file"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'for="issue-attachment"' in html
assert 'id="issue-attachment-preview"' in html
assert 'id="remove-issue-attachment"' in html
assert '.issue-attachment-trigger' in css
assert '.issue-attachment-preview' in css
assert 'min-height:44px' in css
def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
html = INDEX.read_text()
source = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="create-issue-attachment"' in html
assert 'accept="image/png,image/jpeg,image/webp"' in html
assert 'id="create-issue-attachment-preview"' in html
assert 'id="remove-create-issue-attachment"' in html
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
assert "attachment: await createIssueAttachmentController.serialize()" in source
assert "createIssueAttachmentController.restore(queued.attachment);" in source
assert "createIssueAttachmentController.clear();" in source
assert ".create-issue-attachment" in css
assert "overflow-x:hidden" in css
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
script = f"""
const attachment = require({json.dumps(str(ATTACHMENT))});
class Element {{
constructor() {{ this.listeners={{}}; this.hidden=true; this.value=''; this.files=[]; this.textContent=''; this.src=''; }}
addEventListener(type, fn) {{ this.listeners[type]=fn; }}
dispatch(type) {{ return this.listeners[type]({{target:this}}); }}
}}
const input=new Element(), preview=new Element(), image=new Element(), meta=new Element(), remove=new Element(), status=new Element();
const revoked=[];
const controller=attachment.mount({{
input,preview,image,meta,remove,status,
createObjectURL:()=> 'blob:preview', revokeObjectURL:url=>revoked.push(url),
readDataUrl:async()=>'', upload:async()=>{{}},
}});
input.files=[{{name:'payload.svg',type:'image/svg+xml',size:20}}]; input.dispatch('change');
const invalid={{message:status.textContent,hidden:preview.hidden}};
input.files=[{{name:'screen.png',type:'image/png',size:2048}}]; input.dispatch('change');
const selected={{src:image.src,meta:meta.textContent,hidden:preview.hidden,state:controller.state()}};
remove.dispatch('click');
process.stdout.write(JSON.stringify({{invalid,selected,removed:{{hidden:preview.hidden,src:image.src,revoked,state:controller.state()}}}}));
"""
output = json.loads(run_node(script))
assert output["invalid"] == {
"message": "Choose a PNG, JPEG, or WebP screenshot.", "hidden": True
}
assert output["selected"]["src"] == "blob:preview"
assert output["selected"]["hidden"] is False
assert "screen.png" in output["selected"]["meta"]
assert output["removed"] == {
"hidden": True, "src": "", "revoked": ["blob:preview"], "state": None
}
def test_issue_comment_actions_upload_before_posting_and_clear_after_acceptance():
source = DASHBOARD.read_text()
assert "issueAttachment.mount({" in source
assert "'/attachments'" in source
assert "issueAttachmentController.prepareComment(item, body)" in source
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
assert source.count("issueAttachmentController.clear();") >= 2
def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
source = DASHBOARD.read_text()
close_body = re.search(
r"function closeIssueSheet\(navigate = true\) \{(?P<body>.*?)\n \}",
source,
re.DOTALL,
).group("body")
assert "issueAttachmentController.clear();" in close_body
def test_attachment_runtime_is_available_in_the_offline_app_shell():
assert "static/issue-attachment.js" in SERVICE_WORKER.read_text()
def test_readme_documents_mobile_screenshot_limits_and_delivery_order():
readme = README.read_text()
assert "PNG, JPEG, or WebP" in readme
assert "2 MB" in readme
assert "uploads before the comment is posted" in readme
assert "New issue" in readme
assert "retry resumes with the confirmed issue" in readme
+256
View File
@@ -0,0 +1,256 @@
import base64
import httpx
import pytest
from src import gitea_proxy, main
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"mobile screenshot"
@pytest.mark.anyio
async def test_attachment_endpoint_uploads_valid_screenshot_to_assigned_issue(monkeypatch):
calls = []
async def upload(repository, number, filename, content_type, content):
calls.append((repository, number, filename, content_type, content))
return {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"size": len(content),
}
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_issue_attachment", upload, 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/issues/17/attachments",
json={
"filename": "checkout.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"size": len(PNG_BYTES),
"markdown": "![checkout.png](<https://forge.example/attachments/checkout.png>)",
}
assert calls == [
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
]
@pytest.mark.anyio
async def test_attachment_endpoint_admits_a_normal_phone_screenshot(monkeypatch):
screenshot = b"\x89PNG\r\n\x1a\n" + (b"x" * (100 * 1024))
async def upload(_repository, _number, filename, _content_type, content):
return {
"name": filename,
"url": "https://forge.example/attachments/screen.png",
"size": len(content),
}
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_issue_attachment", upload, 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/issues/17/attachments",
json={
"filename": "screen.png",
"content_type": "image/png",
"data": base64.b64encode(screenshot).decode("ascii"),
},
)
assert response.status_code == 201
assert response.json()["size"] == len(screenshot)
@pytest.mark.anyio
async def test_attachment_endpoint_rejects_spoofed_image_before_upstream(monkeypatch):
called = False
async def upload(*_args):
nonlocal called
called = True
monkeypatch.setattr(
main.gitea_proxy, "upload_assigned_issue_attachment", upload, 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/issues/17/attachments",
json={
"filename": "not-really.png",
"content_type": "image/png",
"data": base64.b64encode(b"<script>alert(1)</script>").decode("ascii"),
},
)
assert response.status_code == 422
assert response.headers["cache-control"] == "no-store"
assert called is False
@pytest.mark.anyio
async def test_attachment_endpoint_rejects_image_over_two_megabytes_before_upstream(monkeypatch):
called = False
async def upload(*_args):
nonlocal called
called = True
return {"name": "large.png", "url": "https://forge.example/a", "size": 1}
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
oversized = b"\x89PNG\r\n\x1a\n" + b"x" * (2 * 1024 * 1024 - 7)
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/17/attachments",
json={
"filename": "large.png",
"content_type": "image/png",
"data": base64.b64encode(oversized).decode("ascii"),
},
)
assert len(oversized) == 2 * 1024 * 1024 + 1
assert response.status_code == 422
assert called is False
@pytest.mark.anyio
async def test_gitea_attachment_revalidates_assignment_and_sends_multipart():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET":
return httpx.Response(200, json={
"state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}],
})
return httpx.Response(201, json={
"id": 9,
"name": "checkout.png",
"size": len(PNG_BYTES),
"browser_download_url": "https://forge.example/attachments/checkout.png",
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.upload_assigned_issue_attachment(
"stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES
)
finally:
await gitea_proxy.stop_client()
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("POST", "/api/v1/repos/stackchain/api/issues/17/assets"),
]
upload = requests[-1]
assert upload.url.params["name"] == "checkout.png"
assert "multipart/form-data" in upload.headers["content-type"]
assert b'name="attachment"; filename="checkout.png"' in upload.content
assert PNG_BYTES in upload.content
assert result == {
"name": "checkout.png",
"url": "https://forge.example/attachments/checkout.png",
"size": len(PNG_BYTES),
}
@pytest.mark.anyio
async def test_attachment_markdown_escapes_untrusted_confirmed_filename(monkeypatch):
async def upload(*_args):
return {
"name": "screen](not-an-image).png",
"url": "https://forge.example/attachments/a.png",
"size": len(PNG_BYTES),
}
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
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/17/attachments",
json={
"filename": "screen.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert response.status_code == 201
assert response.json()["markdown"] == (
"![screen\\](not-an-image).png](<https://forge.example/attachments/a.png>)"
)
@pytest.mark.anyio
async def test_attachment_endpoint_hides_an_issue_that_is_no_longer_assigned(monkeypatch):
async def upload(*_args):
raise gitea_proxy.IssueNotAvailableError("not assigned")
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
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/17/attachments",
json={
"filename": "screen.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert response.status_code == 404
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_attachment_endpoint_rejects_unsafe_or_mismatched_filename(monkeypatch):
called = False
async def upload(*_args):
nonlocal called
called = True
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
traversal = await client.post(
"/api/v1/repos/stackchain/api/issues/17/attachments",
json={
"filename": "../payload.png",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
mismatch = await client.post(
"/api/v1/repos/stackchain/api/issues/17/attachments",
json={
"filename": "payload.html",
"content_type": "image/png",
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
},
)
assert [traversal.status_code, mismatch.status_code] == [422, 422]
assert called is False
+82
View File
@@ -71,6 +71,29 @@ process.stdout.write(JSON.stringify({{items:reloaded, stored:JSON.parse(values.g
assert output["stored"]["version"] == 3
def test_issue_outbox_persists_a_validated_screenshot_with_the_capture():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'capture-image-1',
}});
outbox.enqueue({{
repository:'stackchain/dashboard',title:'Layout breaks',body:'At 320px',
attachment:{{filename:'phone.webp',contentType:'image/webp',data:'UklGRg=='}},
}});
process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
"""
output = run_node(script)
assert output["attachment"] == {
"filename": "phone.webp",
"contentType": "image/webp",
"data": "UklGRg==",
}
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -154,6 +177,50 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
assert output["remaining"][0]["status"] == "queued"
def test_foreground_attachment_retry_does_not_create_a_second_issue():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const calls=[];let uploadAttempts=0;
const outbox=createIssueOutbox({{
storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'image-op',
fetchJson:async(url,options={{}})=>{{
calls.push({{url,key:options.headers?.['Idempotency-Key'],body:options.body&&JSON.parse(options.body)}});
if(url.endsWith('/issues'))return{{repository:'o/r',number:7,title:'Visual bug'}};
if(url.endsWith('/attachments') && uploadAttempts++ === 0){{const error=new Error('offline');error.status=503;throw error;}}
if(url.endsWith('/attachments'))return{{markdown:'![screen.png](<https://forge/a.png>)'}};
return{{id:8}};
}},
}});
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'abc'}}}});
(async()=>{{await outbox.flush('timmy');const partial=outbox.list()[0];const result=await outbox.retry(queued.id,'timmy');process.stdout.write(JSON.stringify({{calls,partial,result,remaining:outbox.list()}}));}})();
"""
output = run_node(script)
assert output["partial"]["deliveredIssue"]["number"] == 7
assert [call["url"] for call in output["calls"]].count("api/v1/repos/o/r/issues") == 1
assert output["calls"][-1]["url"] == "api/v1/repos/o/r/issues/7/comments"
assert output["result"]["confirmed"][0]["number"] == 7
assert output["remaining"] == []
def test_replacing_a_partial_capture_screenshot_keeps_issue_and_restarts_upload_stage():
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:()=> 'op-'+(++sequence)}});
const queued=outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'old.png',contentType:'image/png',data:'old'}}}});
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:3,items:[{{...queued,deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:'![old](url)'}}]}}));
const updated=outbox.update(queued.id,{{...queued,attachment:{{filename:'new.png',contentType:'image/png',data:'new'}}}});
process.stdout.write(JSON.stringify(updated));
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachment"]["filename"] == "new.png"
assert "attachmentMarkdown" not in output
def test_issue_outbox_preserves_permanent_failures_for_edit_and_retry():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
@@ -434,6 +501,21 @@ process.stdout.write(JSON.stringify(outbox.list()));
]
def test_page_reconciliation_keeps_confirmed_attachment_delivery_stages():
script = f"""
const createIssueOutbox=require({json.dumps(str(OUTBOX))});
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const outbox=createIssueOutbox({{storage,getOwnerLogin:()=>'timmy',createOperationId:()=>'staged'}});
outbox.enqueue({{repository:'o/r',title:'Visual',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});
outbox.reconcileBackground([{{id:'staged',status:'queued',deliveredIssue:{{repository:'o/r',number:7}},attachmentMarkdown:'![a](url)'}}]);
process.stdout.write(JSON.stringify(outbox.list()[0]));
"""
output = run_node(script)
assert output["deliveredIssue"]["number"] == 7
assert output["attachmentMarkdown"] == "![a](url)"
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/later-sync.js'" in source
+151 -19
View File
@@ -1,14 +1,21 @@
import asyncio
import json
import sqlite3
import httpx
import pytest
from src import main
from src.live_snapshot_store import LiveSnapshotStore
@pytest.fixture(autouse=True)
def reset_live_snapshot_task():
def reset_live_snapshot_task(tmp_path, monkeypatch):
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
main._live_snapshot_task = None
main._live_snapshot_value = None
main._live_snapshot_created_at = None
@@ -131,7 +138,7 @@ async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch
@pytest.mark.anyio
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch):
async def test_live_snapshot_token_from_another_worker_never_suppresses_content(monkeypatch, tmp_path):
async def user():
return {"id": 1, "login": "timmy"}
@@ -144,8 +151,6 @@ async def test_live_snapshot_token_from_another_worker_never_suppresses_content(
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
monkeypatch.setattr(main, "_live_revision_generation", "worker-a")
worker_a = payload(await main.live_snapshot())
worker_a_token = worker_a["revisions"]["context"]
@@ -157,12 +162,15 @@ async def test_live_snapshot_token_from_another_worker_never_suppresses_content(
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
monkeypatch.setattr(main, "_live_revision_generation", "worker-b")
main._live_snapshot_store = LiveSnapshotStore(
tmp_path / "other-worker.sqlite3", clock=lambda: main._live_snapshot_clock()
)
worker_b = payload(await main.live_snapshot(context_revision=worker_a_token))
assert worker_a_token == "worker-a.1"
assert worker_b["revisions"]["context"] == "worker-b.1"
assert worker_a_token.endswith(".1")
assert worker_b["revisions"]["context"].endswith(".1")
assert worker_b["revisions"]["context"] != worker_a_token
assert worker_b["context"]["user"]["login"] == "timmy"
@@ -302,7 +310,10 @@ async def test_live_snapshot_reuses_completed_snapshot_inside_freshness_window(m
first = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
second = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
for _ in range(100):
if user_calls:
break
await asyncio.to_thread(lambda: None)
assert user_calls == 1
release.set()
@@ -332,7 +343,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
"sections": {},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
first = payload(await main.live_snapshot())
@@ -373,13 +384,14 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
stale = payload(await main.live_snapshot())
await asyncio.sleep(0)
assert main._live_snapshot_task is not None
await asyncio.gather(main._live_snapshot_task, return_exceptions=True)
degraded = payload(await main.live_snapshot())
assert stale["freshness"]["revalidating"] is True
@@ -421,13 +433,14 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
await main.live_snapshot()
await asyncio.sleep(0)
assert main._live_snapshot_task is not None
await main._live_snapshot_task
result = payload(await main.live_snapshot())
assert result["context"] == {"generation": 2}
@@ -470,7 +483,7 @@ async def test_notification_cooldown_does_not_stop_due_work_and_activity_refresh
raise ConnectionError("notifications unavailable")
return []
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", work)
monkeypatch.setattr(main, "issues", empty_work)
@@ -537,20 +550,19 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
"sections": {"context": "fresh", "events": "fresh", "notifications": "fresh"},
}
monkeypatch.setattr(main.time, "monotonic", lambda: now)
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", snapshot)
await main.live_snapshot()
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
await main.live_snapshot()
await asyncio.sleep(0)
assert main._live_snapshot_task is not None
await main._live_snapshot_task
now += main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS
first = asyncio.create_task(main.live_snapshot())
second = asyncio.create_task(main.live_snapshot())
await asyncio.sleep(0)
await asyncio.sleep(0)
assert retry_started.is_set()
await asyncio.wait_for(retry_started.wait(), timeout=1.0)
assert builds == 3
assert payload(await first)["freshness"]["revalidating"] is True
assert payload(await second)["freshness"]["revalidating"] is True
@@ -640,3 +652,123 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.anyio
async def test_cold_refresh_cooldown_remains_a_retryable_503(monkeypatch):
now = 100.0
async def unavailable(_sections=None):
raise ConnectionError("private upstream failure")
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: now)
monkeypatch.setattr(main, "_build_live_snapshot", unavailable)
first = await main.live_snapshot()
second = await main.live_snapshot()
assert first.status_code == 503
assert second.status_code == 503
assert payload(second) == {
"error": "Gitea live snapshot is temporarily unavailable"
}
@pytest.mark.anyio
async def test_cancelled_refresh_releases_shared_lease(monkeypatch):
started = asyncio.Event()
async def blocked(_sections=None):
started.set()
await asyncio.Event().wait()
monkeypatch.setattr(main, "_build_live_snapshot", blocked)
request = asyncio.create_task(main.live_snapshot())
await started.wait()
assert main._live_snapshot_task is not None
main._live_snapshot_task.cancel()
await asyncio.gather(request, main._live_snapshot_task, return_exceptions=True)
replacement = main._live_snapshot_store.try_acquire_refresh(
{"context"}, lease_seconds=1
)
assert replacement is not None
@pytest.mark.anyio
async def test_shared_store_outage_returns_retryable_503(monkeypatch):
def unavailable():
raise sqlite3.OperationalError("private database detail")
monkeypatch.setattr(main._live_snapshot_store, "load", unavailable)
response = await main.live_snapshot()
assert response.status_code == 503
assert response.headers["retry-after"] == "1"
assert payload(response) == {
"error": "Gitea live snapshot state is temporarily unavailable"
}
@pytest.mark.anyio
async def test_independent_workers_reuse_shared_live_snapshot(monkeypatch, tmp_path):
calls = 0
async def user():
nonlocal calls
calls += 1
return {"id": 1, "login": "timmy"}
async def empty():
return []
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
path = tmp_path / "worker-shared.sqlite3"
monkeypatch.setattr(main, "_live_snapshot_store", LiveSnapshotStore(path, clock=lambda: 100.0))
first = payload(await main.live_snapshot())
main._live_snapshot_value = None
main._live_snapshot_created_at = None
main._live_section_created_at = {section: None for section in main.LIVE_SNAPSHOT_SECTIONS}
main._live_snapshot_store = LiveSnapshotStore(path, clock=lambda: 100.0)
second = payload(await main.live_snapshot())
assert calls == 1
assert second["context"] == first["context"]
assert second["revisions"] == first["revisions"]
@pytest.mark.anyio
async def test_live_snapshot_persists_reboot_stable_wall_timestamps(monkeypatch, tmp_path):
epoch = 1_700_000_000.0
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
monkeypatch.setattr(main, "_live_snapshot_clock", lambda: epoch, raising=False)
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "wall.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", lambda _user: empty())
monkeypatch.setattr(main, "notifications", empty)
response = await main.live_snapshot()
assert response.status_code == 200
assert set(main._live_snapshot_store.load().created_at.values()) == {epoch}
+157
View File
@@ -0,0 +1,157 @@
import threading
import os
import pytest
from src import live_snapshot_store
from src.live_snapshot_store import LiveSnapshotStore, RefreshLeaseLost
def test_independent_stores_racing_for_refresh_have_one_lease_winner(tmp_path):
path = tmp_path / "live.sqlite3"
first = LiveSnapshotStore(path, clock=lambda: 100.0)
second = LiveSnapshotStore(path, clock=lambda: 100.0)
barrier = threading.Barrier(2)
results = []
def acquire(store):
barrier.wait()
results.append(store.try_acquire_refresh({"context", "events"}, lease_seconds=5))
threads = [threading.Thread(target=acquire, args=(store,)) for store in (first, second)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sum(result is not None for result in results) == 1
def test_snapshot_and_shared_revisions_publish_atomically(tmp_path):
path = tmp_path / "live.sqlite3"
writer = LiveSnapshotStore(path, clock=lambda: 100.0)
reader = LiveSnapshotStore(path, clock=lambda: 100.0)
lease = writer.try_acquire_refresh(set(("context", "events", "notifications")), lease_seconds=5)
assert lease is not None
state = writer.publish_refresh(
lease,
value={
"context": {"generation": 1},
"events": [],
"notifications": [],
"sections": {section: "fresh" for section in ("context", "events", "notifications")},
},
created_at={section: 100.0 for section in ("context", "events", "notifications")},
failure_count={section: 0 for section in ("context", "events", "notifications")},
retry_at={section: None for section in ("context", "events", "notifications")},
changed_sections=set(("context", "events", "notifications")),
)
observed = reader.load()
assert observed.value == state.value
assert observed.revisions == {"context": 1, "events": 1, "notifications": 1}
assert observed.refreshing_sections == set()
with pytest.raises(RefreshLeaseLost):
writer.publish_refresh(
lease,
value={"context": {"generation": 2}},
created_at=observed.created_at,
failure_count=observed.failure_count,
retry_at=observed.retry_at,
changed_sections={"context"},
)
def test_expired_refresh_lease_can_be_recovered(tmp_path):
now = 100.0
path = tmp_path / "live.sqlite3"
abandoned = LiveSnapshotStore(path, clock=lambda: now)
recovery = LiveSnapshotStore(path, clock=lambda: now)
first = abandoned.try_acquire_refresh({"context"}, lease_seconds=5)
assert first is not None
assert recovery.try_acquire_refresh({"context"}, lease_seconds=5) is None
now = 106.0
second = recovery.try_acquire_refresh({"context"}, lease_seconds=5)
assert second is not None
assert second != first
def test_default_store_clock_uses_reboot_stable_wall_time(tmp_path, monkeypatch):
epoch = 1_700_000_000.0
monkeypatch.setattr(live_snapshot_store.time, "time", lambda: epoch)
store = LiveSnapshotStore(tmp_path / "live.sqlite3")
lease = store.try_acquire_refresh({"context"}, lease_seconds=5)
assert lease is not None
assert store.load().lease_expires_at == epoch + 5
def test_store_is_private_and_does_not_persist_upstream_token(tmp_path, monkeypatch):
token = "gitea-super-secret-token-material"
monkeypatch.setenv("GITEA_TOKEN", token)
path = tmp_path / "private-state" / "live.sqlite3"
LiveSnapshotStore(path)
assert os.stat(path).st_mode & 0o777 == 0o600
assert os.stat(path.parent).st_mode & 0o777 == 0o700
assert token.encode() not in path.read_bytes()
def test_remove_notifications_filters_batch_with_one_revision_advance(tmp_path):
store = LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: 100.0)
lease = store.try_acquire_refresh({"notifications"}, lease_seconds=5)
assert lease is not None
metadata = {section: None for section in ("context", "events", "notifications")}
published = store.publish_refresh(
lease,
value={
"notifications": [{"id": 7}, {"id": 8}, {"id": 9}],
"sections": {"notifications": "fresh"},
},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"notifications"},
)
removed = store.remove_notifications([7, 9, 7])
assert removed.value["notifications"] == [{"id": 8}]
assert removed.revisions["notifications"] == published.revisions["notifications"] + 1
def test_read_notification_filter_is_shared_with_future_publications(tmp_path):
path = tmp_path / "live.sqlite3"
first = LiveSnapshotStore(path, clock=lambda: 100.0)
second = LiveSnapshotStore(path, clock=lambda: 100.0)
lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
assert lease is not None
metadata = {section: None for section in ("context", "events", "notifications")}
first.publish_refresh(
lease,
value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"notifications"},
)
removed = second.remove_notification(7)
lease = first.try_acquire_refresh({"notifications"}, lease_seconds=5)
assert lease is not None
republished = first.publish_refresh(
lease,
value={"notifications": [{"id": 7}, {"id": 8}], "sections": {"notifications": "fresh"}},
created_at=metadata,
failure_count={section: 0 for section in metadata},
retry_at=metadata,
changed_sections={"notifications"},
)
assert removed.value["notifications"] == [{"id": 8}]
assert republished.value["notifications"] == [{"id": 8}]
+22
View File
@@ -37,6 +37,28 @@ process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}))
assert state["ignored"] == state["expired"]
def test_idle_session_reason_explains_that_saved_work_will_resume():
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-idle');
process.stdout.write(JSON.stringify({{ status: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["status"] == (
"Stackchain locked after inactivity. Your drafts and queued work are still "
"on this device. Sign in to resume."
)
def test_revoked_session_reason_clears_private_data_before_enabling_sign_in():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v67" in worker
assert "stackchain-dashboard-shell-v81" in worker
+85
View File
@@ -0,0 +1,85 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MENTIONS = ROOT / "frontend" / "mention-composer.js"
def run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def test_active_mention_replaces_only_the_token_at_the_caret():
script = f"""
const mentions=require({json.dumps(str(MENTIONS))});
const text='Thanks @al for this; keep @casey informed';
const caret=text.indexOf(' for');
const active=mentions.activeMention(text, caret);
const inserted=mentions.insertMention(text, active, 'alex');
process.stdout.write(JSON.stringify({{active,inserted}}));
"""
result = run_node(script)
assert result["active"] == {"start": 7, "end": 10, "query": "al"}
assert result["inserted"] == {
"value": "Thanks @alex for this; keep @casey informed",
"caret": 13,
}
def test_controller_ignores_stale_results_and_keyboard_selects_a_teammate():
script = f"""
const mentions=require({json.dumps(str(MENTIONS))});
class Element {{
constructor() {{ this.listeners={{}}; this.children=[]; this.attrs={{}}; this.dataset={{}}; this.hidden=true; this.value=''; this.selectionStart=0; this.selectionEnd=0; this.textContent=''; }}
addEventListener(type, fn) {{ (this.listeners[type] ||= []).push(fn); }}
dispatch(type, extra={{}}) {{ const event={{preventDefault(){{ this.prevented=true; }}, ...extra}}; (this.listeners[type] || []).forEach(fn=>fn(event)); return event; }}
replaceChildren(...children) {{ this.children=children; }}
appendChild(child) {{ this.children.push(child); }}
setAttribute(name, value) {{ this.attrs[name]=String(value); }}
removeAttribute(name) {{ delete this.attrs[name]; }}
setSelectionRange(start, end) {{ this.selectionStart=start; this.selectionEnd=end; }}
focus() {{ this.focused=true; }}
}}
const textarea=new Element(), listbox=new Element(), status=new Element();
const pending=[];
const controller=mentions.create({{
textarea,listbox,status,getRepository:()=> 'stackchain/api',
loadCandidates:(repository, query)=>new Promise(resolve=>pending.push({{repository,query,resolve}})),
setTimer:fn=>{{ fn(); return 1; }}, clearTimer:()=>{{}},
createOption:()=>new Element(),
}});
controller.start();
(async()=>{{
textarea.value='Ping @al'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
textarea.value='Ping @alex'; textarea.selectionStart=textarea.value.length; textarea.dispatch('input');
pending[1].resolve([{{login:'alex',name:'Alexander'}}]); await Promise.resolve(); await Promise.resolve();
pending[0].resolve([{{login:'alice',name:'Alice'}}]); await Promise.resolve(); await Promise.resolve();
const before={{queries:pending.map(item=>item.query), options:listbox.children.map(item=>item.dataset.login), expanded:textarea.attrs['aria-expanded']}};
textarea.dispatch('keydown', {{key:'ArrowDown'}});
const enter=textarea.dispatch('keydown', {{key:'Enter'}});
process.stdout.write(JSON.stringify({{before,value:textarea.value,caret:textarea.selectionStart,focused:textarea.focused,prevented:enter.prevented,hidden:listbox.hidden}}));
}})().catch(error=>{{ console.error(error); process.exit(1); }});
"""
result = run_node(script)
assert result["before"] == {
"queries": ["al", "alex"],
"options": ["alex"],
"expanded": "true",
}
assert result["value"] == "Ping @alex "
assert result["caret"] == 11
assert result["focused"] is True
assert result["prevented"] is True
assert result["hidden"] is True
+19 -1
View File
@@ -35,4 +35,22 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v67" in worker
assert "stackchain-dashboard-shell-v81" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert '<script src="static/mention-composer.js"></script>' in html
for composer in ("issue-comment", "pull-comment", "update-reply"):
assert f'id="{composer}-mentions"' in html
assert f'id="{composer}-mention-status"' in html
assert f"textarea:qs('#{composer}')" in dashboard
assert f"listbox:qs('#{composer}-mentions')" in dashboard
assert dashboard.count("createMentionComposer({") == 3
assert "mention-candidates?q=" in dashboard
assert ".mention-options" in css
assert "min-height:44px" in css
assert "max-width:100%" in css
+2 -1
View File
@@ -180,7 +180,8 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
assert "startToday: startTodaySession" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
assert "workSession.reopen()" in html
assert "workSession.reopen(item)" in html
assert "runTodayTransition('continue')" in html
@pytest.mark.anyio
+432 -16
View File
@@ -12,6 +12,7 @@ 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"
LATER_AND_START = Path(__file__).parents[1] / "frontend" / "later-and-start.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"
@@ -1153,6 +1154,219 @@ process.stdout.write(JSON.stringify({{
}
def test_starting_deferred_work_moves_it_to_today_before_opening_exact_item():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Resume me'}};
const controller = createLaterAndStart({{
todayWork: {{
identity:saved => saved.repository + '#' + saved.number,
add:saved => {{ calls.push(['today-add', saved.number]); return 'added'; }},
remove:saved => {{ calls.push(['today-remove', saved.number]); return true; }},
}},
todaySync: {{
enqueue:(action, identity) => {{ calls.push(['today-sync', action, identity]); return true; }},
flush:() => calls.push(['today-flush']),
}},
laterWork: {{restore:saved => {{ calls.push(['later-restore', saved.number]); return true; }}}},
refresh:() => calls.push(['refresh']),
warm:() => calls.push(['warm']),
start:saved => {{ calls.push(['start', saved.number]); return Promise.resolve('opened'); }},
announce:message => calls.push(['announce', message]),
}});
(async () => {{
const result = await controller.start(item);
process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "started",
"calls": [
["today-add", 17],
["today-sync", "add", "stackchain/api#17"],
["later-restore", 17],
["refresh"],
["today-flush"],
["warm"],
["start", 17],
["announce", "Moved to Today and opened."],
],
}
def test_starting_deferred_work_keeps_later_when_today_is_full():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const controller = createLaterAndStart({{
todayWork: {{identity:() => 'issue:x/y:7', add:() => 'full', remove:() => calls.push('remove')}},
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
laterWork: {{restore:() => calls.push('restore')}},
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
start:() => calls.push('start'), announce:message => calls.push(message),
}});
(async () => {{
const result = await controller.start({{kind:'issue',repository:'x/y',number:7}});
process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"result": "full",
"calls": ["Today is limited to 5 items. Remove one, then try Start now again."],
}
def test_starting_deferred_work_rolls_back_today_when_sync_admission_fails():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'pull',repository:'x/y',number:8}};
const controller = createLaterAndStart({{
todayWork: {{
identity:() => 'pull:x/y:8', add:() => {{ calls.push('add'); return 'added'; }},
remove:() => {{ calls.push('remove'); return true; }},
}},
todaySync: {{enqueue:() => {{ calls.push('enqueue'); return false; }}, flush:() => calls.push('flush')}},
laterWork: {{restore:() => calls.push('restore')}},
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
start:() => calls.push('start'), announce:message => calls.push(message),
}});
(async () => {{
const result = await controller.start(item);
process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"result": "sync-unavailable",
"calls": [
"add", "enqueue", "remove",
"Today sync is unavailable. The item remains in Later; try again.",
],
}
def test_starting_deferred_work_rolls_back_today_when_later_cannot_be_removed():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'review',repository:'x/y',number:9}};
const controller = createLaterAndStart({{
todayWork: {{
identity:() => 'review:x/y:9', add:() => 'added',
remove:() => {{ calls.push('remove'); return true; }},
}},
todaySync: {{
enqueue:(action, id) => {{ calls.push(['enqueue', action, id]); return true; }},
flush:() => calls.push('flush'),
}},
laterWork: {{restore:() => false}}, refresh:() => calls.push('refresh'),
warm:() => calls.push('warm'), start:() => calls.push('start'),
announce:message => calls.push(message),
}});
(async () => {{
const result = await controller.start(item);
process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"result": "later-unavailable",
"calls": [
["enqueue", "add", "review:x/y:9"], "remove",
["enqueue", "remove", "review:x/y:9"], "flush",
"Could not remove this item from Later. Nothing was started; try again.",
],
}
def test_starting_deferred_work_reuses_existing_today_item_without_duplicate_sync():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const calls = [];
const item = {{kind:'issue',repository:'x/y',number:10}};
const controller = createLaterAndStart({{
todayWork: {{identity:() => 'issue:x/y:10', add:() => 'exists', remove:() => calls.push('remove')}},
todaySync: {{enqueue:() => calls.push('enqueue'), flush:() => calls.push('flush')}},
laterWork: {{restore:() => {{ calls.push('restore'); return true; }}}},
refresh:() => calls.push('refresh'), warm:() => calls.push('warm'),
start:saved => calls.push(['start', saved.number]), announce:message => calls.push(message),
}});
(async () => {{
const result = await controller.start(item);
process.stdout.write(JSON.stringify({{result,calls}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"result": "started",
"calls": [
"restore", "refresh", "warm", ["start", 10],
"Opened the existing Today item.",
],
}
def test_starting_deferred_work_is_single_flight_for_repeated_taps():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
let adds = 0;
let finishStart;
const item = {{kind:'issue',repository:'x/y',number:11}};
const controller = createLaterAndStart({{
todayWork: {{identity:() => 'issue:x/y:11', add:() => {{ adds += 1; return 'added'; }}, remove:() => true}},
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
refresh:() => {{}}, warm:() => {{}}, announce:() => {{}},
start:() => new Promise(resolve => {{ finishStart = resolve; }}),
}});
const first = controller.start(item);
const second = controller.start(item);
if (adds !== 1) throw new Error('duplicate admission');
finishStart('opened');
(async () => {{
const results = await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{adds,results}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {"adds": 1, "results": ["started", "started"]}
def test_starting_blocked_deferred_work_reports_readiness_gate_without_claiming_open():
script = f"""
const createLaterAndStart = require({json.dumps(str(LATER_AND_START))});
const messages = [];
const controller = createLaterAndStart({{
todayWork: {{identity:() => 'issue:x/y:12', add:() => 'added', remove:() => true}},
todaySync: {{enqueue:() => true, flush:() => {{}}}}, laterWork: {{restore:() => true}},
refresh:() => {{}}, warm:() => {{}}, start:() => Promise.resolve('gated'),
announce:message => messages.push(message),
}});
(async () => {{
const result = await controller.start({{kind:'issue',repository:'x/y',number:12}});
process.stdout.write(JSON.stringify({{result,messages}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"result": "gated",
"messages": ["Moved to Today. Choose how to handle its blocker before starting."],
}
def test_detail_defer_closes_normal_triage_but_keeps_session_open_for_reconcile():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
@@ -1223,6 +1437,75 @@ process.stdout.write(JSON.stringify({{saved,calls}}));
}
def test_detail_defer_completes_checkpointed_today_item_after_saving_later():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const item = {{kind:'issue',repository:'stackchain/api',number:17,title:'Read first'}};
const controller = createDetailDefer({{
laterWork: {{
presetUntil:() => new Date('2026-08-09T09:00:00Z'),
defer:(saved, until) => {{ calls.push(['defer', saved.title, until.toISOString()]); return 'deferred'; }},
restore:() => calls.push(['restore']),
}},
session: {{active:() => true, checkpointed:saved => saved === item}},
continueSession:saved => {{ calls.push(['continue', saved.title]); return true; }},
close:() => calls.push(['close']), refresh:() => calls.push(['refresh']),
focus:() => calls.push(['focus']), announce:message => calls.push(['announce', message]),
}});
const saved = controller.defer(item, 'tomorrow');
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": True,
"calls": [
["defer", "Read first", "2026-08-09T09:00:00.000Z"],
["continue", "Read first"],
],
}
def test_detail_defer_rolls_back_later_when_today_cannot_be_removed():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
const controller = createDetailDefer({{
laterWork: {{
presetUntil:() => new Date('2026-08-09T09:00:00Z'),
defer:() => {{ calls.push('defer'); return 'deferred'; }},
restore:saved => {{ calls.push(['restore', saved.number]); return true; }},
}},
session: {{active:() => true, checkpointed:() => true}},
continueSession:() => {{ calls.push('continue'); return false; }},
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
focus:() => calls.push('focus'), announce:message => calls.push(['announce', message]),
}});
const saved = controller.defer(item, 'tomorrow');
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": [
"defer",
"continue",
["restore", 17],
"refresh",
["announce", "Could not remove this item from Today, so it was restored from Later."],
],
}
@pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
html = await dashboard()
@@ -1238,6 +1521,18 @@ async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutat
assert "fetch(" not in DETAIL_DEFER.read_text()
@pytest.mark.anyio
async def test_checkpointed_detail_defer_completes_today_and_advertises_next_item():
html = await dashboard()
assert "continueSession: item => completeTodayItem(item, {" in html
assert "successMessage: 'Deferred to Later. Next Today item opened.'" in html
assert "advance: () => runTodayTransition('complete')" in html
assert "button.textContent = active ? 'Later today & next' : 'Later today'" in html
assert "button.textContent = active ? 'Tomorrow & next' : 'Tomorrow'" in html
assert "button.textContent = active ? 'Choose date & time & next' : 'Choose date & time'" in html
@pytest.mark.anyio
async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
html = await dashboard()
@@ -1258,6 +1553,24 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
assert "'Deferred until ' + fmt(until)" in html
@pytest.mark.anyio
async def test_mobile_later_cards_start_exact_item_in_a_resumable_today_session():
html = await dashboard()
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert '<script src="static/later-and-start.js"></script>' in html
assert 'data-later-start' in html
assert '>Start now</button>' in html
assert 'aria-label="Deferred work actions"' in html
assert "const laterAndStart = createLaterAndStart({" in html
assert "laterAndStart.start(item)" in html
assert "qs('[data-work-filter=\"today\"]').click();" in html
assert "todayReadiness.run('start', workSession.items(), item)" in html
assert "selectedWorkFilter === 'later' ? laterActions" in html
assert ".later-actions button { min-height:44px; width:100%; }" in html
assert "BASE + 'static/later-and-start.js'" in service_worker
@pytest.mark.anyio
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
html = await dashboard()
@@ -1648,7 +1961,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume()" in html
assert "workSession.resume(item)" in html
assert "runTodayTransition('resume')" in html
assert "workSession.end()" in html
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@@ -1662,7 +1976,7 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
assert "qs('#start-work-session').addEventListener('click'" in html
assert "document.querySelectorAll('[data-work-session-previous]')" in html
assert "document.querySelectorAll('[data-work-session-next]')" in html
assert 'workSession.complete();' in html
assert "runTodayTransition('complete')" in html
assert 'workSession.reconcile();' in html
for opener in ('openIssueSheet(item', 'openPullSheet(item', 'openReviewSheet(item', 'notificationReader.open(item'):
assert opener in html
@@ -1672,17 +1986,56 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
html = await dashboard()
assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html
assert "workSession.active() ? 'Close & next' : 'Close issue'" in html
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
"qs('#close-pull-sheet').addEventListener", 1
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
assert "if (workSession.active()) workSession.complete();" in close_handler
assert "workSession.active() ? await runTodayTransition('complete') : null" in close_handler
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
@pytest.mark.anyio
async def test_releasing_checkpointed_issue_completes_today_through_readiness_transition():
html = await dashboard()
assert "workSession.checkpointed(item) ? 'Release & next' : 'Release assignment'" in html
release_handler = html.split("qs('#release-issue').addEventListener('click'", 1)[1].split(
"qs('#load-issue-handoff').addEventListener", 1
)[0]
assert "const continuingSession = workSession.checkpointed(releasing);" in release_handler
assert "await completeOwnershipExitToday(releasing)" in release_handler
assert "paintMyWork(lastContextSnapshot);" in release_handler
assert release_handler.index("await issueController.release") < release_handler.index(
"await completeOwnershipExitToday(releasing)"
)
assert release_handler.index("buildMyWork.removeIssue") < release_handler.index(
"await completeOwnershipExitToday(releasing)"
)
@pytest.mark.anyio
async def test_handing_off_checkpointed_issue_completes_today_through_readiness_transition():
html = await dashboard()
assert "workSession.checkpointed(item) ? 'Hand off & next' : 'Confirm handoff'" in html
handoff_handler = html.split("qs('#confirm-issue-handoff').addEventListener('click'", 1)[1].split(
"qs('#close-issue').addEventListener", 1
)[0]
assert "const continuingSession = workSession.checkpointed(handingOff);" in handoff_handler
assert "await completeOwnershipExitToday(handingOff)" in handoff_handler
assert "paintMyWork(lastContextSnapshot);" in handoff_handler
assert handoff_handler.index("await issueController.handoff") < handoff_handler.index(
"await completeOwnershipExitToday(handingOff)"
)
assert handoff_handler.index("buildMyWork.removeIssue") < handoff_handler.index(
"await completeOwnershipExitToday(handingOff)"
)
@@ -1696,13 +2049,13 @@ async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_n
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
assert "const continuingSession = workSession.active();" in merge_handler
assert "if (workSession.active()) workSession.complete();" in merge_handler
assert "workSession.active() ? await runTodayTransition('complete') : null" in merge_handler
assert "merged. Next work item opened." in merge_handler
assert merge_handler.index("await pullController.merge") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
"await runTodayTransition('complete')"
)
@@ -1729,7 +2082,7 @@ async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
assert "lastMyWork = lastMyWork.filter" in success_path
assert "workSession.complete()" in success_path
assert "runTodayTransition('complete')" in success_path
assert "lastMyWork = lastMyWork.filter" not in failure_path
assert "workSession.complete()" not in failure_path
assert "The pull request remains in My Work; refresh and retry." in failure_path
@@ -3948,12 +4301,45 @@ process.stdout.write(JSON.stringify({{ html, expanded: button.attrs['aria-expand
assert 'class="review-diff-line review-inline-target removed"' in output["html"]
assert 'data-old-position="1"' in output["html"]
assert 'data-new-position="1"' in output["html"]
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old">1</span><span class="review-line-number new"></span></span>' in output["html"]
assert '<span class="review-line-numbers" aria-hidden="true"><span class="review-line-number old"></span><span class="review-line-number new">1</span></span>' in output["html"]
assert '<span class="review-line-code">-old &lt;token&gt;</span>' in output["html"]
assert '<span class="review-line-code">+new &amp; safe</span>' in output["html"]
assert 'aria-label="Comment on src/&lt;api&gt;.py line 1"' in output["html"]
assert 'Mark reviewed' in output["html"]
assert output["expanded"] == "true"
assert output["hidden"] is False
def test_review_wrap_preference_defaults_to_phone_layout_and_persists_explicit_choice():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
}};
const phone = reviewSheet.createWrapPreference({{storage, mobile: true}});
const initial = phone.snapshot();
const disabled = phone.setWrapped(false);
const restoredPhone = reviewSheet.createWrapPreference({{storage, mobile: true}}).snapshot();
const restoredDesktop = reviewSheet.createWrapPreference({{storage, mobile: false}}).snapshot();
process.stdout.write(JSON.stringify({{initial, disabled, restoredPhone, restoredDesktop,
stored: values.get('stackchain.review-wrap.v1')}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"initial": {"wrapped": True, "explicit": False},
"disabled": {"wrapped": False, "explicit": True},
"restoredPhone": {"wrapped": False, "explicit": True},
"restoredDesktop": {"wrapped": False, "explicit": True},
"stored": "false",
}
def test_review_diff_parser_maps_multi_hunk_lines_to_old_or_new_positions():
script = f"""
const reviewSheet = require({json.dumps(str(REVIEW_SHEET))});
@@ -3971,14 +4357,14 @@ process.stdout.write(JSON.stringify(rows));
assert json.loads(result.stdout) == [
{"text": "@@ -10,3 +20,4 @@ function run()", "kind": "hunk", "commentable": False},
{"text": " context", "kind": "context", "commentable": True, "new_position": 20},
{"text": "-removed", "kind": "removed", "commentable": True, "old_position": 11},
{"text": "+added", "kind": "added", "commentable": True, "new_position": 21},
{"text": "+second", "kind": "added", "commentable": True, "new_position": 22},
{"text": " context", "kind": "context", "commentable": True, "old_line": 10, "new_line": 20, "new_position": 20},
{"text": "-removed", "kind": "removed", "commentable": True, "old_line": 11, "old_position": 11},
{"text": "+added", "kind": "added", "commentable": True, "new_line": 21, "new_position": 21},
{"text": "+second", "kind": "added", "commentable": True, "new_line": 22, "new_position": 22},
{"text": "\\ No newline at end of file", "kind": "note", "commentable": False},
{"text": "@@ -40 +51 @@", "kind": "hunk", "commentable": False},
{"text": "-old tail", "kind": "removed", "commentable": True, "old_position": 40},
{"text": "+new tail", "kind": "added", "commentable": True, "new_position": 51},
{"text": "-old tail", "kind": "removed", "commentable": True, "old_line": 40, "old_position": 40},
{"text": "+new tail", "kind": "added", "commentable": True, "new_line": 51, "new_position": 51},
]
@@ -4205,6 +4591,36 @@ async def test_review_requests_open_an_accessible_mobile_detail_sheet():
assert '.review-diff' in html and 'overflow-x:auto' in html
@pytest.mark.anyio
async def test_mobile_review_wraps_long_lines_with_gutters_and_persisted_toggle():
html = await dashboard()
assert 'id="review-wrap-lines"' in html
assert 'aria-pressed="false"' in html
assert 'aria-controls="review-files"' in html
assert "createReviewController.createWrapPreference" in html
assert "matchMedia('(max-width: 600px)').matches" in html
assert "reviewFilesElement.classList.toggle('wrap-lines', snapshot.wrapped)" in html
assert "wrapPreference.setWrapped" in html
assert '.review-display-tools button' in html and 'min-height:44px' in html
assert '.review-line-numbers' in html and 'grid-template-columns:4ch 4ch' in html
assert '.review-line-code' in html and 'overflow-wrap:anywhere' in html
assert '.wrap-lines .review-diff' in html and 'overflow-x:hidden' in html
assert '.wrap-lines .review-diff-line' in html and 'min-width:0' in html
assert '.wrap-lines .review-line-code' in html and 'white-space:pre-wrap' in html
@pytest.mark.anyio
async def test_review_attention_drafts_require_revision_instead_of_resending_stale_payload():
html = await dashboard()
assert "const reviewOutbox = item.outbox_kind === 'pull-review';" in html
assert "reviewOutbox && item.status === 'attention'" in html
assert ">Open current review</button>" in html
assert ">Copy feedback</button>" in html
assert "item.kind === 'authored-outbox' && !reviewOutbox" in html
@pytest.mark.anyio
async def test_review_sheet_loads_details_and_preserves_safe_gitea_handoff():
html = await dashboard()
+69
View File
@@ -5,6 +5,17 @@ import httpx
import pytest
from src import gitea_proxy, main
from src.live_snapshot_store import LiveSnapshotStore
@pytest.fixture(autouse=True)
def isolated_live_snapshot_store(tmp_path, monkeypatch):
monkeypatch.setattr(
main,
"_live_snapshot_store",
LiveSnapshotStore(tmp_path / "live.sqlite3", clock=lambda: main._live_snapshot_clock()),
)
monkeypatch.setattr(main, "_read_notification_ids", set())
@pytest.mark.anyio
@@ -50,6 +61,37 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_snapshot_maintenance_does_not_block_the_event_loop(monkeypatch):
async def mark(_thread_id):
return None
calls = []
class BlockingStore:
def remove_notification(self, thread_id):
calls.append([thread_id])
time.sleep(0.08)
def remove_notifications(self, thread_ids):
calls.append(list(thread_ids))
time.sleep(0.08)
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "_live_snapshot_store", BlockingStore())
monkeypatch.setattr(main, "_live_snapshot_value", None)
started_at = asyncio.get_running_loop().time()
request = asyncio.create_task(main.read_notification(42))
await asyncio.sleep(0.01)
heartbeat_delay = asyncio.get_running_loop().time() - started_at
response = await request
assert heartbeat_delay < 0.05
assert response.status_code == 200
assert calls == [[42]]
@pytest.mark.anyio
async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures(monkeypatch):
calls = []
@@ -78,6 +120,33 @@ async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_updates_shared_snapshot_once_for_successes(monkeypatch):
async def mark(thread_id):
if thread_id == 43:
raise httpx.HTTPError("upstream unavailable")
calls = []
class RecordingStore:
def remove_notifications(self, thread_ids):
calls.append(tuple(sorted(thread_ids)))
monkeypatch.setattr(main, "mark_notification_read", mark)
monkeypatch.setattr(main, "_live_snapshot_store", RecordingStore())
monkeypatch.setattr(
main,
"_live_snapshot_value",
{"notifications": [{"id": 42}, {"id": 43}, {"id": 44}]},
)
response = await main.read_notifications(main.NotificationReadBatch(ids=[42, 43, 44]))
assert response.status_code == 200
assert calls == [(42, 44)]
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
@pytest.mark.anyio
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
active = 0
+35
View File
@@ -0,0 +1,35 @@
import json
import subprocess
from pathlib import Path
OFFLINE_CLOSE = Path(__file__).parents[1] / "frontend" / "offline-issue-close.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_offline_issue_close_waits_for_durable_admission_before_advancing_today():
script = f"""
const createOfflineIssueClose = require({json.dumps(str(OFFLINE_CLOSE))});
const events=[]; let release;
const gate=new Promise(resolve=>release=resolve);
const close=createOfflineIssueClose({{
enqueueDurably:async message=>{{events.push('admit');await gate;events.push('durable');return {{item:message,background:true}};}},
completeToday:item=>{{events.push('complete:' + item.number);return true;}},
}});
const item={{kind:'issue',repository:'stackchain/dashboard',number:27}};
const pending=close(item).then(result=>{{events.push('resolved');return result;}});
Promise.resolve().then(async()=>{{
const before=events.slice();release();const result=await pending;
process.stdout.write(JSON.stringify({{before,events,result}}));
}});
"""
output = run_node(script)
assert output["before"] == ["admit"]
assert output["events"] == ["admit", "durable", "complete:27", "resolved"]
assert output["result"]["advanced"] is True
assert output["result"]["admission"]["background"] is True
+48 -3
View File
@@ -264,7 +264,7 @@ async def test_saved_unread_update_opens_offline_with_queued_reply_and_read_cont
@pytest.mark.anyio
async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only():
async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offline():
html = await dashboard()
assert "item.is_review ? reviewController.load(item)" in html
@@ -272,6 +272,51 @@ async def test_saved_requested_review_opens_offline_for_sha_scoped_drafting_only
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 "Queue review for reconnect" in html
assert "kind: 'pull-review'" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "expectedHeadSha: selectedReviewHead" in html
assert "comments: snapshot.comments" in html
assert "draftKey: draft.storageKey" in html
assert "progressKey: progress?.storageKey" in html
assert "draftFingerprint: localStorage.getItem(draft.storageKey) || ''" in html
assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html
assert "Review queued · it will submit after reconnect." in html
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html
@pytest.mark.anyio
async def test_offline_today_review_queues_durably_before_completing_and_advancing():
html = await dashboard()
assert "function reviewingActiveTodayItem()" in html
assert "offlineReview && reviewingActiveTodayItem() ? 'Queue review & next'" in html
handler = html.split("qs('#submit-review').addEventListener('click'", 1)[1].split(
"qs('#continue-review-to-merge').addEventListener", 1
)[0]
admission = handler.index("await authoredOutbox.enqueueDurably")
completion = handler.index("completeTodayItem(selectedReview")
assert admission < completion
assert "successMessage: 'Review queued. Next Today item opened.'" in handler
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
assert "if (!advanced)" in handler
assert "Review queued, but Today still needs completion." in handler
@pytest.mark.anyio
async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
html = await dashboard()
assert '<script src="static/offline-issue-close.js"></script>' in html
assert "const closeOfflineIssue = createOfflineIssueClose({" in html
assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
"qs('#close-pull-sheet').addEventListener", 1
)[0]
admission = handler.index("await closeOfflineIssue(closing)")
sheet_close = handler.index("closeIssueSheet()")
assert admission < sheet_close
assert "Issue closure queued for reconnect." in handler
assert "The issue remains in Today; retry." in handler
+126 -2
View File
@@ -79,6 +79,54 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
}
def test_plan_today_calculates_capacity_and_confirms_overcommitment_before_saving():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const saved = [];
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: plan => saved.push(plan),
}});
planner.open([item(1), item(2)], [item(1), item(2)]);
planner.setCapacity(90);
planner.setEstimate('issue:stackchain/dashboard:1:', 60);
planner.setEstimate('issue:stackchain/dashboard:2:', 45);
const over = planner.snapshot();
const confirmation = planner.commit();
const stillOpen = planner.snapshot().open;
const result = planner.commit({{confirmOverCapacity:true}});
process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, saved}}));
"""
result = run_node(script)
assert result["over"] == {
"open": True,
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
"count": 2,
"limit": 5,
"capacity_minutes": 90,
"estimates": {
"issue:stackchain/dashboard:1:": 60,
"issue:stackchain/dashboard:2:": 45,
},
"planned_minutes": 105,
"remaining_minutes": -15,
"unestimated_count": 0,
"over_capacity": True,
}
assert result["confirmation"] == "confirm-over-capacity"
assert result["stillOpen"] is True
assert result["result"] == "saved"
assert result["saved"] == [{
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
"capacity_minutes": 90,
"estimates": {
"issue:stackchain/dashboard:1:": 60,
"issue:stackchain/dashboard:2:": 45,
},
}]
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@@ -126,6 +174,64 @@ process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:pla
}
def test_plan_today_preview_requires_explicit_override_for_blocked_or_unknown_issue():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
planner.open([], [item(2), item(3)]);
const preview = createPlanTodayPreview({{
planner,
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
}});
preview.open(item(2));
preview.setDependencies({{available:true, dependencies:[{{repository:'stackchain/api', number:9, title:'Restore API', state:'open'}}]}});
const blockedState = preview.snapshot();
const blocked = preview.close({{add:true}});
const overridden = preview.close({{add:true, override:true}});
preview.open(item(3));
preview.setDependencies({{available:false, dependencies:[]}});
const unavailableState = preview.snapshot();
const unavailable = preview.close({{add:true}});
const unavailableOverride = preview.close({{add:true, override:true}});
process.stdout.write(JSON.stringify({{blockedState, blocked, overridden, unavailableState, unavailable, unavailableOverride, planner:planner.snapshot()}}));
"""
assert run_node(script) == {
"blockedState": {
"open": True,
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 2, "title": "Issue 2"},
"trigger": None,
"scroll": 0,
"dependencies_available": True,
"dependencies": [{"repository": "stackchain/api", "number": 9, "title": "Restore API", "state": "open"}],
"requires_override": True,
},
"blocked": "blocked",
"overridden": "added",
"unavailableState": {
"open": True,
"item": {"kind": "issue", "repository": "stackchain/dashboard", "number": 3, "title": "Issue 3"},
"trigger": None,
"scroll": 0,
"dependencies_available": False,
"dependencies": [],
"requires_override": True,
},
"unavailable": "dependencies-unavailable",
"unavailableOverride": "added",
"planner": {
"open": True,
"ids": ["issue:stackchain/dashboard:2:", "issue:stackchain/dashboard:3:"],
"count": 2,
"limit": 5,
},
}
@pytest.mark.anyio
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
html = await dashboard()
@@ -134,6 +240,17 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="plan-today"' in html
assert 'id="plan-today-sheet" role="dialog"' in html
assert 'id="plan-today-capacity"' in html
assert 'id="plan-today-available"' in html
assert 'inputmode="numeric" min="15" max="1440"' in html
assert 'data-plan-estimate="' in html
assert 'aria-label="Estimate for ' in html
assert "formatPlanMinutes(state.planned_minutes)" in html
assert "planToday.setCapacity" in html
assert "planToday.setEstimate" in html
assert "todaySync.enqueueConfiguration" in html
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
assert "resetPlanTodayConfirmation()" in html
assert "result === 'confirm-over-capacity'" in html
assert 'id="save-and-start-today"' in html
assert "const planToday = createPlanToday({" in html
assert "todaySync.enqueue('remove'" in html
@@ -150,6 +267,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="add-plan-preview"' in html
assert "taskOverlayHistory.open('plan-today-preview')" in html
assert "planTodayPreview.close({ add:true })" in html
assert 'id="issue-blockers"' in html
assert "planTodayPreview.setDependencies({" in html
assert "Add blocked item anyway & back" in html
assert "Blocker status unavailable" in html
assert ".issue-blockers { max-width:100%; overflow-x:hidden;" in html
assert ".issue-blocker { min-width:0; overflow-wrap:anywhere;" in html
assert "Preview unavailable offline" in html
@@ -162,12 +285,13 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
assert "openPlanToday(planTodayTrigger, false)" in html
assert "closePlanToday(false)" in html
assert "if (result === 'saved') taskOverlayHistory.leave();" in html
assert "if (result === 'saved')" in html
assert "taskOverlayHistory.leave();" in html
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+7
View File
@@ -112,6 +112,13 @@ def test_request_limits_are_route_specific_and_cover_api_mutations():
== 64 * 1024
)
assert main.request_body_limit("GET", "/api/v1/context") is None
assert (
main.request_body_limit(
"POST", "/api/v1/repos/stackchain/project/issues/17/attachments"
)
== 3 * 1024 * 1024
)
assert main.request_body_limit("POST", "/unrelated/attachments") is None
assert main.request_body_limit("POST", "/unrelated") is None
+69 -11
View File
@@ -13,7 +13,7 @@ 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, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -47,8 +47,9 @@ 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; }},
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; state.outboxLifecycle.push('purge'); }},
resume: async () => {{ state.backgroundResumes += 1; state.outboxLifecycle.push('resume'); }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
@@ -121,16 +122,31 @@ async function dispatchNotificationClick(route) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -138,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" 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-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -154,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" 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-v67" in source
assert "stackchain-dashboard-shell-v81" 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-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -221,6 +237,26 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
]
def test_idle_background_session_preserves_outbox_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_idle'}),{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_idle"}
assert result["state"]["outboxPurges"] == 0
assert result["state"]["deleted"] == []
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-idle"}
]
def test_authenticated_page_message_resumes_queued_background_delivery():
result = run_worker_scenario(
"""
@@ -232,6 +268,21 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
assert result["backgroundFlushes"] == 1
def test_authenticated_resume_rearms_a_previously_purged_worker_before_flushing():
result = run_worker_scenario(
"""
const replies = [];
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage:value=>replies.push(value)}]);
await dispatchMessage({type:'stackchain-resume-outbox'});
process.stdout.write(JSON.stringify({state,replies}));
"""
)
assert result["replies"] == [{"ok": True}]
assert result["state"]["outboxLifecycle"] == ["purge", "resume", "flush"]
assert result["state"]["backgroundResumes"] == 1
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario(
"""
@@ -334,7 +385,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -365,22 +416,28 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-issue-close.js",
"/dashboard/static/notification-read-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/dashboard/static/comment-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
"/dashboard/static/later-and-start.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
@@ -397,6 +454,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",
"/dashboard/static/background-issue-sync.js",
}
+56
View File
@@ -77,6 +77,43 @@ def test_expired_sessions_are_rejected_without_writing_during_validation(tmp_pat
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
def test_session_status_reports_idle_without_background_validation_extending_activity(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
now[0] = 1_899.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
now[0] = 1_900.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
def test_touch_extends_only_the_matching_live_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
store.activate("laptop-session", 3_000)
now[0] = 1_800.0
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is True
now[0] = 2_000.0
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "active"
assert store.status("laptop-session", 3_000, idle_timeout_seconds=900) == "idle"
assert store.touch("missing-session", 3_000, idle_timeout_seconds=900) is False
def test_touch_cannot_revive_a_session_at_the_idle_boundary(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("phone-session", 3_000)
now[0] = 1_900.0
assert store.touch("phone-session", 3_000, idle_timeout_seconds=900) is False
assert store.status("phone-session", 3_000, idle_timeout_seconds=900) == "idle"
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")
@@ -121,6 +158,25 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa
assert next(device for device in devices if device.current).device_label == "Existing device"
def test_idle_status_migrates_existing_registry_and_starts_legacy_idle_clock_now(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)
assert store.status("existing-session", 2_000, idle_timeout_seconds=900) == "active"
with sqlite3.connect(database) as connection:
last_active_at = connection.execute(
"SELECT last_active_at FROM active_sessions WHERE session_hash = ?", (digest,)
).fetchone()[0]
assert last_active_at == 1_000
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")
+237
View File
@@ -0,0 +1,237 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).parents[1]
READINESS = ROOT / "frontend" / "today-readiness.js"
SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_readiness_opens_ready_target_without_inspecting_the_rest_of_the_queue():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const calls = [];
const inspections = [];
const item = (number, kind='issue') => ({{kind, repository:'stackchain/dashboard', number, title:'Item ' + number}});
const readiness = createTodayReadiness({{
inspect: async candidate => {{ inspections.push(candidate.number); return {{available:true, dependencies:[]}}; }},
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
onGate: state => calls.push(['gate', state.status]),
}});
readiness.run('start', [item(1), item(2), item(3)]).then(result =>
process.stdout.write(JSON.stringify({{result, calls, inspections}}))
);
"""
assert json.loads(run_node(script)) == {
"result": "opened",
"calls": [["open", "start", 1]],
"inspections": [1],
}
def test_readiness_checks_fallbacks_concurrently_but_preserves_queue_order():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number}}));
const starts = [];
const resolvers = {{}};
const readiness = createTodayReadiness({{
inspect: candidate => {{
starts.push(candidate.number);
if (candidate.number === 1) return Promise.resolve({{available:true, dependencies:[{{state:'open'}}]}});
return new Promise(resolve => {{ resolvers[candidate.number] = resolve; }});
}},
onOpen: () => undefined,
onGate: state => {{ globalThis.gated = state.nextReady.number; }},
}});
(async () => {{
const running = readiness.run('start', items);
await new Promise(resolve => setImmediate(resolve));
const startedTogether = starts.slice();
if (resolvers[3]) resolvers[3]({{available:true, dependencies:[]}});
resolvers[2]({{available:true, dependencies:[]}});
await new Promise(resolve => setImmediate(resolve));
if (!startedTogether.includes(3)) resolvers[3]({{available:true, dependencies:[]}});
const result = await running;
process.stdout.write(JSON.stringify({{result, startedTogether, gated:globalThis.gated}}));
}})();
"""
assert json.loads(run_node(script)) == {
"result": "gated",
"startedTogether": [1, 2, 3],
"gated": 2,
}
def test_readiness_gates_blocked_target_and_offers_earliest_ready_without_reordering():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const calls = [];
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number, title:'Issue ' + number}}));
const readiness = createTodayReadiness({{
inspect: async candidate => candidate.number === 1
? {{available:true, dependencies:[{{repository:'r', number:99, title:'Prerequisite', state:'open', url:'https://forge.example/r/issues/99'}}]}}
: {{available:true, dependencies:[]}},
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
onGate: state => calls.push(['gate', state.status, state.target.number, state.nextReady.number, state.items.map(item => item.number)]),
}});
readiness.run('resume', items, items[0]).then(async result => {{
const opened = readiness.startNextReady();
process.stdout.write(JSON.stringify({{result, opened, calls, order:items.map(item => item.number)}}));
}});
"""
assert json.loads(run_node(script)) == {
"result": "gated",
"opened": True,
"calls": [
["gate", "blocked", 1, 2, [1, 2, 3]],
["open", "resume", 2],
],
"order": [1, 2, 3],
}
def test_readiness_previews_only_a_current_blocker_without_closing_or_reordering_gate():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const previews = [];
const items = [1, 2].map(number => ({{kind:'issue', repository:'r', number}}));
const blocker = {{repository:'r', number:99, title:'Prerequisite', state:'open'}};
const readiness = createTodayReadiness({{
inspect: async candidate => candidate.number === 1
? {{available:true, dependencies:[blocker]}}
: {{available:true, dependencies:[]}},
onPreview: candidate => previews.push(candidate.number),
}});
(async () => {{
await readiness.run('start', items, items[0]);
const accepted = readiness.previewBlocker(blocker);
const rejected = readiness.previewBlocker({{repository:'r', number:100}});
const snapshot = readiness.snapshot();
process.stdout.write(JSON.stringify({{
accepted, rejected, previews,
open:snapshot.status === 'blocked',
target:snapshot.target.number,
order:snapshot.items.map(item => item.number),
}}));
}})();
"""
assert json.loads(run_node(script)) == {
"accepted": True,
"rejected": False,
"previews": [99],
"open": True,
"target": 1,
"order": [1, 2],
}
def test_readiness_never_calls_unknown_ready_and_supports_retry_or_explicit_override():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
const calls = [];
const target = {{kind:'issue', repository:'r', number:1}};
let available = false;
const readiness = createTodayReadiness({{
inspect: async () => available ? {{available:true, dependencies:[]}} : {{available:false, dependencies:[]}},
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
onGate: state => calls.push(['gate', state.status]),
}});
readiness.run('next', [target], target).then(async result => {{
available = true;
const retried = await readiness.retry();
available = false;
await readiness.run('continue', [target], target);
const overridden = readiness.workAnyway();
process.stdout.write(JSON.stringify({{result, retried, overridden, calls}}));
}});
"""
assert json.loads(run_node(script)) == {
"result": "gated",
"retried": "opened",
"overridden": True,
"calls": [
["gate", "unknown"],
["open", "next", 1],
["gate", "unknown"],
["open", "continue", 1],
],
}
def test_readiness_treats_non_issue_work_as_runnable_without_dependency_io():
script = f"""
const createTodayReadiness = require({json.dumps(str(READINESS))});
let inspections = 0;
const pull = {{kind:'pull', repository:'r', number:7}};
const readiness = createTodayReadiness({{
inspect: async () => {{ inspections += 1; throw new Error('not expected'); }},
onOpen: () => undefined,
onGate: () => undefined,
}});
readiness.run('start', [pull]).then(result =>
process.stdout.write(JSON.stringify({{result, inspections}}))
);
"""
assert json.loads(run_node(script)) == {"result": "opened", "inspections": 0}
@pytest.mark.anyio
async def test_dashboard_exposes_mobile_today_readiness_gate_and_runtime():
html = await dashboard()
assert '<script src="static/today-readiness.js"></script>' in html
assert 'id="today-readiness-sheet"' in html
assert 'id="today-readiness-next"' in html
assert 'id="today-readiness-anyway"' in html
assert 'id="today-readiness-retry"' in html
assert 'id="today-readiness-plan"' in html
assert "createTodayReadiness({" in html
assert "runTodayTransition('start'" in html
assert "runTodayTransition('resume'" in html
assert "runTodayTransition('continue'" in html
assert "runTodayTransition('next'" in html
assert "runTodayTransition('complete'" in html
@pytest.mark.anyio
async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate():
html = await dashboard()
assert 'data-today-blocker-index="' in html
assert "todayReadiness.previewBlocker(blocker)" in html
assert "searchPreview.open({ ...blocker, kind:'issue' })" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "suspendTodayReadiness()" in html
assert "todayReadiness.cancel()" in html
assert "todayReadinessBlockerFocus" in html
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v81';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
def test_mobile_readiness_gate_has_touch_safe_wrapping_actions():
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert ".today-readiness-actions button" in css
assert "min-height:44px" in css
assert ".today-readiness-item" in css
assert "overflow-wrap:anywhere" in css
assert "env(safe-area-inset-bottom)" in css
assert ".today-readiness-blocker" in css
assert "min-height:44px" in css
+97 -2
View File
@@ -36,6 +36,8 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
"revision": 1,
"ids": ["issue:stackchain/dashboard:1:"],
"capacity_minutes": None,
"estimates": {},
}
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
@@ -53,9 +55,49 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
"issue:stackchain/dashboard:3:",
"issue:stackchain/dashboard:2:",
],
"capacity_minutes": None,
"estimates": {},
}
assert TodayStore(path, limit=3).get("timmy") == moved
assert store.get("alexander") == {"revision": 0, "ids": []}
assert store.get("alexander") == {
"revision": 0,
"ids": [],
"capacity_minutes": None,
"estimates": {},
}
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, limit=3)
store.apply("timmy", "add-1", "add", "issue:r:1:")
store.apply("timmy", "add-2", "add", "issue:r:2:")
configured = store.apply_batch("timmy", [{
"operation_id": "capacity-1",
"action": "configure",
"item_id": "plan",
"capacity_minutes": 180,
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45, "issue:r:99:": 30},
"base_revision": 2,
}])
store.apply("timmy", "move-2", "move", "issue:r:2:", direction="up")
store.apply("timmy", "remove-1", "remove", "issue:r:1:")
assert configured["capacity_minutes"] == 180
assert configured["estimates"] == {"issue:r:1:": 60, "issue:r:2:": 45}
assert TodayStore(path, limit=3).get("timmy") == {
"revision": 5,
"ids": ["issue:r:2:"],
"capacity_minutes": 180,
"estimates": {"issue:r:2:": 45},
}
assert store.get("alexander") == {
"revision": 0,
"ids": [],
"capacity_minutes": None,
"estimates": {},
}
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
@@ -69,6 +111,8 @@ def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
assert store.get("timmy") == {
"revision": 2,
"ids": ["issue:r:1:", "issue:r:2:"],
"capacity_minutes": None,
"estimates": {},
}
store.apply("timmy", "remove", "remove", "issue:r:1:")
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
@@ -94,6 +138,8 @@ def test_batch_applies_ordered_operations_once_and_rejects_only_capacity_conflic
assert result == {
"revision": 4,
"ids": ["issue:r:2:", "issue:r:3:"],
"capacity_minutes": None,
"estimates": {},
"accepted_operation_ids": ["one", "two", "remove", "three"],
"duplicate_operation_ids": [],
"rejected_operations": [{"operation_id": "full", "reason": "today_full"}],
@@ -197,6 +243,48 @@ def test_today_api_model_preserves_client_base_revision():
assert operation.model_dump()["base_revision"] == 7
def test_today_api_model_accepts_bounded_capacity_configuration():
operation = main.TodayOperation(
operation_id="capacity", action="configure", item_id="plan",
capacity_minutes=180,
estimates={"issue:r:1:": 60, "issue:r:2:": 45},
base_revision=2,
)
assert operation.model_dump() == {
"operation_id": "capacity",
"action": "configure",
"item_id": "plan",
"direction": None,
"base_revision": 2,
"capacity_minutes": 180,
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45},
}
@pytest.mark.anyio
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
async def user():
return {"login": "timmy"}
calls = []
class Store:
def apply_batch(self, login, operations):
calls.append((login, operations))
return {"revision": 1, "ids": [], "capacity_minutes": 180, "estimates": {}}
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: Store())
payload = main.TodayOperation(
operation_id="capacity", action="configure", item_id="plan", capacity_minutes=180,
)
result = await main.update_today_plan(payload)
assert result["capacity_minutes"] == 180
assert calls == [("timmy", [payload.model_dump()])]
@pytest.mark.anyio
async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
@@ -246,9 +334,16 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
assert changed.json() == {
"revision": 2,
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
"capacity_minutes": None,
"estimates": {},
"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.json() == {
"revision": 2,
"ids": ["issue:stackchain/dashboard:357:", "issue:stackchain/dashboard:359:"],
"capacity_minutes": None,
"estimates": {},
}
assert fetched.headers["cache-control"] == "no-store"
+42 -1
View File
@@ -83,10 +83,51 @@ sync.enqueue('add', 'issue:r:1:');
}
def test_today_capacity_configuration_syncs_offline_and_adopts_remote_plan():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map(); let delivered; let adopted; let broadcast;
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const sync = createTodaySync({{
storage, getLogin:()=> 'timmy', createOperationId:()=> 'capacity-op',
createChannel:()=>({{addEventListener() {{}},postMessage:plan=>{{broadcast=plan}},close() {{}}}}),
fetchJson:async(_url, options={{}})=>{{
if (!options.method) return {{revision:2,ids:['issue:r:1:'],capacity_minutes:null,estimates:{{}}}};
delivered=JSON.parse(options.body).operations[0];
return {{revision:3,ids:['issue:r:1:'],capacity_minutes:120,estimates:{{'issue:r:1:':60}},accepted_operation_ids:['capacity-op'],duplicate_operation_ids:[],rejected_operations:[]}};
}},
onRemotePlan:plan=>{{adopted=plan}}, onRemoteIds:()=>{{}}, onStatus:()=>{{}},
}});
sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{delivered,adopted,broadcast,pending:sync.pending()}}));}})();
"""
assert json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout) == {
"delivered": {
"operation_id": "capacity-op", "action": "configure", "item_id": "plan",
"direction": None, "capacity_minutes": 120,
"estimates": {"issue:r:1:": 60}, "base_revision": 0,
},
"adopted": {
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
"estimates": {"issue:r:1:": 60},
"accepted_operation_ids": ["capacity-op"],
"duplicate_operation_ids": [], "rejected_operations": [],
},
"broadcast": {
"revision": 3, "ids": ["issue:r:1:"], "capacity_minutes": 120,
"estimates": {"issue:r:1:": 60},
},
"pending": [],
}
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-v67" in source
assert "stackchain-dashboard-shell-v81" in source
assert "BASE + 'static/today-sync.js'" in source
+153
View File
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
TODAY_COMPLETION = Path(__file__).parents[1] / "frontend" / "today-completion.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
@@ -62,6 +64,45 @@ process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted,
}
def test_today_capacity_metadata_is_account_scoped_and_prunes_removed_estimates():
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)}};
let login = 'timmy';
const queue = createTodayWork({{storage, getLogin: () => login}});
queue.replace(['issue:r:1:', 'issue:r:2:']);
const saved = queue.replacePlanning({{capacity_minutes:120,estimates:{{'issue:r:1:':60,'issue:r:2:':45}}}});
queue.replace(['issue:r:2:']);
const timmy = queue.planning();
login = 'alexander';
const alexander = queue.planning();
process.stdout.write(JSON.stringify({{saved,timmy,alexander}}));
"""
assert json.loads(run_node(script)) == {
"saved": True,
"timmy": {"capacity_minutes": 120, "estimates": {"issue:r:2:": 45}},
"alexander": {"capacity_minutes": None, "estimates": {}},
}
def test_today_estimated_runway_follows_current_session_position():
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.replace(['issue:r:1:','issue:r:2:','issue:r:3:']);
queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:r:2:':60,'issue:r:3:':45}}}});
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
"""
assert json.loads(run_node(script)) == {
"first": {"current_minutes": 30, "remaining_minutes": 135},
"second": {"current_minutes": 60, "remaining_minutes": 105},
}
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
@@ -87,6 +128,102 @@ process.stdout.write(JSON.stringify([
]
def test_done_for_today_removes_current_plan_item_syncs_and_advances_without_gitea_mutation():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const calls = [];
const current = {{kind:'issue', repository:'stackchain/dashboard', number:421}};
const complete = createTodayCompletion({{
todayWork: {{
identity: item => `issue:${{item.repository}}:${{item.number}}:`,
remove: item => {{ calls.push(`remove:${{item.number}}`); return true; }},
}},
todaySync: {{
enqueue: (action, identity) => {{ calls.push(`sync:${{action}}:${{identity}}`); return true; }},
flush: () => calls.push('flush'),
}},
refresh: () => calls.push('refresh'),
warm: () => calls.push('warm'),
workSession: {{ complete: () => calls.push('advance') }},
announce: message => calls.push(`announce:${{message}}`),
}});
const completed = complete(current);
process.stdout.write(JSON.stringify({{completed, calls}}));
"""
assert json.loads(run_node(script)) == {
"completed": True,
"calls": [
"remove:421",
"sync:remove:issue:stackchain/dashboard:421:",
"flush",
"refresh",
"warm",
"advance",
"announce:Done for Today. The Gitea item is unchanged.",
],
}
def test_done_for_today_does_not_advance_when_local_plan_persistence_fails():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const calls = [];
const complete = createTodayCompletion({{
todayWork: {{identity: () => 'issue:r:1:', remove: () => false}},
todaySync: {{enqueue: () => calls.push('sync'), flush: () => calls.push('flush')}},
refresh: () => calls.push('refresh'),
warm: () => calls.push('warm'),
workSession: {{complete: () => calls.push('advance')}},
announce: message => calls.push(message),
}});
const completed = complete({{kind:'issue', repository:'r', number:1}});
process.stdout.write(JSON.stringify({{completed, calls}}));
"""
assert json.loads(run_node(script)) == {
"completed": False,
"calls": ["Could not update Today on this device. Try again."],
}
def test_today_completion_reports_context_specific_success_and_partial_failure():
script = f"""
const createTodayCompletion = require({json.dumps(str(TODAY_COMPLETION))});
const messages = [];
let removable = false;
const complete = createTodayCompletion({{
todayWork: {{identity: () => 'review:r:7:', remove: () => removable}},
todaySync: {{enqueue: () => true, flush: () => undefined}},
refresh: () => undefined,
warm: () => undefined,
workSession: {{complete: () => undefined}},
announce: message => messages.push(message),
}});
const options = {{
successMessage: 'Review queued. Next Today item opened.',
failureMessage: 'Review queued, but Today still needs completion.',
}};
const failed = complete({{kind:'review', repository:'r', number:7}}, options);
removable = true;
const completed = complete({{kind:'review', repository:'r', number:7}}, options);
process.stdout.write(JSON.stringify({{failed, completed, messages}}));
"""
assert json.loads(run_node(script)) == {
"failed": False,
"completed": True,
"messages": [
"Review queued, but Today still needs completion.",
"Review queued. Next Today item opened.",
],
}
def test_done_for_today_logic_is_available_in_the_offline_app_shell():
assert "BASE + 'static/today-completion.js'" in SERVICE_WORKER.read_text()
def test_authoritative_reconciliation_reports_retired_ids_before_local_prune():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
@@ -183,6 +320,22 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert "Another device filled Today · showing its saved plan." in html
@pytest.mark.anyio
async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_action():
html = await dashboard()
assert '<script src="static/today-completion.js"></script>' in html
assert html.count('data-work-session-complete=') == 4
assert html.count('Done for Today &amp; next') == 4
assert "button.hidden = !workSession.checkpointed();" in html
assert "const completeTodayItem = createTodayCompletion({" in html
assert "refresh: () => refreshMyWorkView({ reconcileSession:false })," in html
assert "document.querySelectorAll('[data-work-session-complete]')" in html
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
@pytest.mark.anyio
async def test_dashboard_activates_today_lifecycle_convergence():
html = await dashboard()