Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1052ce2c84 | ||
|
|
4f0e6544b3 | ||
|
|
91c59a2953 | ||
|
|
6d1fa3c068 | ||
|
|
21a386b94f | ||
|
|
7e5b966f77 | ||
|
|
78d044f1af | ||
|
|
814c5726ec | ||
|
|
95a14c6de5 | ||
|
|
37aa2dd090 | ||
|
|
6565165295 | ||
|
|
4749d15d85 | ||
|
|
16604d697b | ||
|
|
59b4d5e6bf | ||
|
|
83af187c73 | ||
|
|
d4896e10be | ||
|
|
81e1af7f16 | ||
|
|
b1b3f758e4 | ||
|
|
ced644b275 | ||
|
|
131848cc43 | ||
|
|
62a0841eb5 | ||
|
|
cc63fc6499 | ||
|
|
9dede4bf2a | ||
|
|
b667963968 | ||
|
|
31b0e79aa1 | ||
|
|
8206856d94 | ||
|
|
7ec3330175 | ||
|
|
ec339d242d | ||
|
|
d161cf41c0 | ||
|
|
535658185a | ||
|
|
8b7c56161c | ||
|
|
2b2e2cceb4 | ||
|
|
de596f397f | ||
|
|
af86fc1e98 | ||
|
|
5bf491c725 | ||
|
|
11341ec5d9 | ||
|
|
eefb760169 | ||
|
|
3f9dd49766 | ||
|
|
72453b76db | ||
|
|
7639a42d7b | ||
|
|
0b992543f1 | ||
|
|
9904f17e3d | ||
|
|
0dd3932d14 | ||
|
|
8e6ee96ede | ||
|
|
161977cb67 | ||
|
|
e3c07c2dfb | ||
|
|
a610708e36 | ||
|
|
f8c5c2b058 | ||
|
|
72c470b652 | ||
|
|
b214a49686 | ||
|
|
a9508984e2 | ||
|
|
20d5176713 | ||
|
|
c96ed51b0d | ||
|
|
cf2e49300a | ||
|
|
5e49f27142 | ||
|
|
0280077e45 | ||
|
|
f781410e80 | ||
|
|
0311f85d96 | ||
|
|
5a6ef62c4c | ||
|
|
461c796227 | ||
|
|
6309ed43cd | ||
|
|
a0aa3c0a22 | ||
|
|
60bc7cd686 | ||
|
|
79c8f1626b | ||
|
|
cd9f40a1c4 | ||
|
|
0a3fb180ed | ||
|
|
74d1435ca7 | ||
|
|
8989e93c2c | ||
|
|
7b3b7dadb9 | ||
|
|
05f9e06a10 | ||
|
|
e0de45ea90 | ||
|
|
3224389441 | ||
|
|
63723e7fa3 | ||
|
|
740e91a615 | ||
|
|
0363f23cef | ||
|
|
2cbeba30fd | ||
|
|
43595de52f | ||
|
|
c451cd0bcb | ||
|
|
ad27db6846 | ||
|
|
791082bae2 | ||
|
|
d3ed0cca0c | ||
|
|
ccb595a081 | ||
|
|
7b1c9a608c | ||
|
|
854d040e88 | ||
|
|
95baa20eeb | ||
|
|
0cf88d7d7a | ||
|
|
969d5567d4 |
No files matched your search
@@ -19,9 +19,32 @@ 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; the mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted; validation or upload
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
||||
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
||||
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
||||
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
||||
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
|
||||
creation and assignment permission. Once a repository and meaningful title are selected,
|
||||
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. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
|
||||
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
|
||||
repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
@@ -37,10 +60,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,16 +104,30 @@ 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
|
||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
|
||||
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
|
||||
is ready. Claims have unique fencing tokens and are renewed before every network stage;
|
||||
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
|
||||
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
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 +136,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
|
||||
@@ -107,8 +160,16 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
||||
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
||||
# Recommended behind a proxy; WebAuthn assertions must match these public values.
|
||||
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
||||
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
||||
# 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,18 +203,52 @@ 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.
|
||||
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, sign-outs, remote device revocations, issue
|
||||
closures, and pull-request merges. The separate SQLite journal retains at most
|
||||
10,000 events for 90 days and stores only bounded device labels and action targets.
|
||||
It never stores access tokens, cookies, session/CSRF values, credential IDs, raw
|
||||
network addresses, request bodies, or comment content. Keep its database on the
|
||||
same class of persistent, writable storage as the session registry. Before a
|
||||
session revocation or issue closure, the journal durably reserves a pending event;
|
||||
if that reservation fails, the destructive action does not begin. A successful
|
||||
action remains truthfully reported even if its event cannot immediately be finalized,
|
||||
and the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||
|
||||
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
|
||||
WebAuthn credential with required user verification. That device can then sign in
|
||||
and authorize high-impact actions with its biometric/PIN gesture. The access token
|
||||
remains the recovery fallback for browsers without WebAuthn or devices without an
|
||||
enrolled credential. Registration and authentication challenges are exact-purpose,
|
||||
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
|
||||
including those whose original session has expired or signed out, using only its
|
||||
bounded label, enrollment time, and active/current status. Removing one requires a
|
||||
single-use fresh authorization bound to that exact management ID. A remote active
|
||||
session linked to the credential is revoked atomically; unrelated credentials and
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in.
|
||||
|
||||
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.
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
operator access token again.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
first use. Expired, replayed, cross-session, and target-substituted grants fail before
|
||||
Gitea or session state is changed. The browser preserves the pending request and
|
||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||
|
||||
If an active session expires, the first authenticated API rejection replaces the
|
||||
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 +313,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 +353,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 +386,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 +404,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
|
||||
|
||||
+125
-13
@@ -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', 'update-reply-read', '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,23 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(message.kind === 'issue-comment' && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
stored: true,
|
||||
},
|
||||
} : {}),
|
||||
...(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);
|
||||
@@ -56,8 +102,22 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
|
||||
...candidate,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
} : candidate);
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
} catch (error) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'authored');
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
@@ -95,6 +155,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';
|
||||
}
|
||||
@@ -102,6 +168,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const request = (async () => {
|
||||
try {
|
||||
let result;
|
||||
@@ -114,29 +184,68 @@ 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 === 'update-reply-read') {
|
||||
if (!item.replyConfirmed) {
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||
} : candidate), false);
|
||||
}
|
||||
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
} else 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) {
|
||||
const status = Number(error?.status || 0);
|
||||
const permanent = status >= 400 && status < 500;
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (permanent) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt,
|
||||
lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
}
|
||||
@@ -151,7 +260,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention') continue;
|
||||
if (item.status === 'attention' || item.kind === 'issue-close') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
@@ -196,7 +305,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',
|
||||
|
||||
@@ -35,6 +35,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
||||
Promise.resolve(work({
|
||||
get: id => requested(objectStore.get(id)),
|
||||
getAll: () => requested(objectStore.getAll()),
|
||||
put: value => requested(objectStore.put(value)),
|
||||
delete: id => requested(objectStore.delete(id)),
|
||||
@@ -54,7 +55,11 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
|
||||
} = {}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
|
||||
async function reconcile(items, outboxLane = 'issue') {
|
||||
@@ -65,13 +70,20 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
const replacement = incoming.get(current.id);
|
||||
let replacement = incoming.get(current.id);
|
||||
if (!replacement) {
|
||||
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
||||
await records.delete(current.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.operationId === replacement.operationId &&
|
||||
(current.attachment?.data || current.attachment?.blob) &&
|
||||
replacement.attachment?.stored &&
|
||||
!replacement.attachment.data && !replacement.attachment.blob) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(current.status === 'attention' && replacement.status === 'attention') ||
|
||||
current.status === 'sent') {
|
||||
@@ -87,21 +99,25 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const timestamp = Number(now());
|
||||
const items = await records.getAll();
|
||||
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
if (!item) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function claimBatch(ownerLogin, limit = 70) {
|
||||
async function planBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
@@ -111,14 +127,10 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift());
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
|
||||
}
|
||||
const claimed = selected.map(item => ({
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs,
|
||||
}));
|
||||
for (const item of claimed) await records.put(item);
|
||||
return claimed;
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +141,9 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
if (!item || item.ownerLogin !== ownerLogin ||
|
||||
!['queued', 'sending'].includes(item.status) ||
|
||||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
|
||||
const claimed = { ...item, status: 'sending', claimUntil: timestamp + claimMs };
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
@@ -141,8 +155,16 @@ 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 preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,6 +175,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
});
|
||||
}
|
||||
|
||||
async function updateClaim(id, claimToken, transform) {
|
||||
return transact(async records => {
|
||||
const item = records.get ? await records.get(id) :
|
||||
(await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
|
||||
return false;
|
||||
}
|
||||
await records.put(transform(item));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function clearClaim(item, changes) {
|
||||
const { claimToken: _claimToken, ...unclaimed } = item;
|
||||
return { ...unclaimed, ...changes, claimUntil: 0 };
|
||||
}
|
||||
|
||||
async function renew(id, claimToken) {
|
||||
let renewed = null;
|
||||
await updateClaim(id, claimToken, item => {
|
||||
renewed = { ...item, claimUntil: Number(now()) + claimMs };
|
||||
return renewed;
|
||||
});
|
||||
return renewed;
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
@@ -175,18 +223,24 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||
}
|
||||
|
||||
return {
|
||||
get: id => transact(records => records.get(id)),
|
||||
reconcile,
|
||||
upsert,
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
claimBatch,
|
||||
complete: (id, deliveredIssue) => update(id, item => ({
|
||||
...item, status: 'sent', claimUntil: 0,
|
||||
planBatch,
|
||||
supportsClaimTokens: true,
|
||||
renew,
|
||||
checkpoint: updateClaim,
|
||||
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'sent',
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
|
||||
fail: (id, error, deliveryState) => update(id, item => ({
|
||||
...item, status: 'attention', claimUntil: 0, error,
|
||||
release: (id, claimToken) => updateClaim(id, claimToken,
|
||||
item => clearClaim(item, { status: 'queued' })),
|
||||
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
snapshot: () => transact(async records =>
|
||||
@@ -205,10 +259,29 @@ 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);
|
||||
|
||||
const completeClaim = (item, delivered) => store.supportsClaimTokens
|
||||
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
|
||||
const releaseClaim = item => store.supportsClaimTokens
|
||||
? store.release(item.id, item.claimToken) : store.release(item.id);
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
async function requestStage(item, url, options) {
|
||||
if (store.renew) {
|
||||
const renewed = await store.renew(item.id, item.claimToken);
|
||||
if (!renewed) throw new Error('Background delivery claim was lost.');
|
||||
}
|
||||
return requestJson(url, options);
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
@@ -238,10 +311,22 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||
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) };
|
||||
@@ -259,7 +344,41 @@ function createBackgroundIssueSync({
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
if (item.kind === 'update-reply-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
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,28 +421,154 @@ function createBackgroundIssueSync({
|
||||
};
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
authoredRequest('', current).options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
current = { ...current, replyConfirmed: true };
|
||||
}
|
||||
const request = deliveryRequest(current);
|
||||
return requestStage(current, request.url, request.options);
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
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 requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestStage(
|
||||
item,
|
||||
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 deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = await requestJson(request.url, request.options);
|
||||
await store.complete(item.id, delivered);
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.attachment && item.kind === 'issue-comment' ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, 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 completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await store.release(item.id);
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await store.fail(
|
||||
item.id,
|
||||
await failClaim(
|
||||
item,
|
||||
String(error?.message || 'Issue needs attention').slice(0, 240),
|
||||
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
||||
);
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await store.release(item.id);
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -352,7 +597,30 @@ function createBackgroundIssueSync({
|
||||
if (result.attention) attention += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.claimBatch) {
|
||||
if (store.planBatch) {
|
||||
const planned = await store.planBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < planned.length) {
|
||||
const id = planned[next++];
|
||||
const item = await store.claim(id, login);
|
||||
if (!item) continue;
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) throw new Error('Background delivery canceled.');
|
||||
if (authenticationError) throw authenticationError;
|
||||
if (transientError) throw transientError;
|
||||
} else if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
@@ -397,15 +665,27 @@ 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,
|
||||
get: id => store.get(id),
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admitQueued(item, message) {
|
||||
const admission = await queue(message);
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
return admitQueued(item, {
|
||||
...identity, body,
|
||||
operationId: typeof operationId === 'function' ? operationId() : operationId,
|
||||
});
|
||||
}
|
||||
accept(item, { delivery: 'posted', comment });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: 'posted',
|
||||
comment,
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function admit(item, message) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, admit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
|
||||
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
@@ -159,6 +160,25 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return request;
|
||||
}
|
||||
|
||||
async function searchRepositories(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++repositorySearchRequest;
|
||||
if (query.length < 2) return {status: 'idle', items: []};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
|
||||
);
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {
|
||||
status: 'ready',
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {status: 'failed', items: [], error};
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -230,7 +250,8 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
|
||||
+77
-5
@@ -16,11 +16,22 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button { min-height:44px; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
|
||||
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
|
||||
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
|
||||
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
@@ -89,6 +100,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 +115,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; }
|
||||
@@ -114,6 +137,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.delivery-center h3, .delivery-center p { margin:0; }
|
||||
.delivery-center button { min-height:44px; flex:0 0 auto; }
|
||||
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
|
||||
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
|
||||
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
@@ -179,9 +208,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 +241,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 +253,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 +272,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 +313,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,11 +354,18 @@ 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-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||
#create-issue-repository-results[hidden] { display:none; }
|
||||
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
.create-issue-repository-result:last-child { border-bottom:0; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
@@ -388,9 +457,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
|
||||
+895
-74
File diff suppressed because it is too large.
Load diff
@@ -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.');
|
||||
|
||||
+41
-7
@@ -112,7 +112,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'completion' ? 'completion' : (item.status === 'attention' ? 'attention' : 'queued'),
|
||||
status: item.status === 'completion' ? 'completion' :
|
||||
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
@@ -125,6 +126,8 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
};
|
||||
});
|
||||
@@ -138,26 +141,42 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
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 isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||
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',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(isClosure ? 'authorization' : '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 ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure,
|
||||
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') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
@@ -216,7 +235,22 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
return { list, discard };
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
(item.status === 'authorization' ? 'authorization' : 'waiting'));
|
||||
summary[state] += 1;
|
||||
return summary;
|
||||
}, { waiting:0, sending:0, attention:0, authorization:0 });
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
+111
-7
@@ -30,13 +30,32 @@
|
||||
</div>
|
||||
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices">
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-activity-title">Security activity</h3>
|
||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||
</div>
|
||||
<div id="security-activity-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="security-activity-list" class="security-activity-list"></div>
|
||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -180,6 +199,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 +218,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 & back</button>
|
||||
@@ -262,6 +302,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 +314,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 & next</button>
|
||||
</div>
|
||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="issue-planning" id="issue-planning">
|
||||
@@ -333,6 +392,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 & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -366,9 +426,15 @@
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-title">Title
|
||||
@@ -377,6 +443,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 +514,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-read-next" type="button">Reply, mark read & next</button>
|
||||
</div>
|
||||
<div id="update-reply-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<div class="update-sheet-actions">
|
||||
@@ -448,6 +532,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 & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -468,7 +553,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 & next</button>
|
||||
</div>
|
||||
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
<details class="pull-review" id="pull-review">
|
||||
@@ -488,6 +578,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 & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -507,6 +598,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 +647,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 & next</button>
|
||||
<button type="button" data-work-session-next>Next work item</button>
|
||||
</nav>
|
||||
</section>
|
||||
@@ -590,22 +686,29 @@
|
||||
<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/update-reply-read-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 +725,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>
|
||||
@@ -0,0 +1,254 @@
|
||||
(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 namedBlob(blob, name) {
|
||||
if (typeof File === 'function') {
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
if (file.size <= MAX_BYTES) return file;
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') {
|
||||
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
||||
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => {
|
||||
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
|
||||
});
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
function multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const upload = options.upload;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selectionGeneration = 0;
|
||||
|
||||
function commitSelection(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = createOperationId();
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
const generation = ++selectionGeneration;
|
||||
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) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
});
|
||||
}
|
||||
return commitSelection(file);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) {
|
||||
clear();
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
serialized = blob ? { filename, contentType, blob } : { 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) {
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
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,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: operationId,
|
||||
});
|
||||
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 = '';
|
||||
let selectionSequence = 0;
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
options.input.disabled = false;
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function showPreview(file, optimized) {
|
||||
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 = optimized ? 'Screenshot optimized and ready to upload.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
const sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, false);
|
||||
return;
|
||||
}
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
return result.then(() => controller.serialize()).then(value => {
|
||||
if (sequence === selectionSequence) showPreview(value.blob, true);
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
}
|
||||
}).finally(() => {
|
||||
if (sequence === selectionSequence) options.input.disabled = false;
|
||||
});
|
||||
});
|
||||
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 = value.blob ? options.createObjectURL(value.blob) :
|
||||
'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, multipart, optimizeImage, MAX_BYTES };
|
||||
});
|
||||
+178
-30
@@ -5,6 +5,18 @@ 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 blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (blob) return { filename, contentType, blob };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
@@ -23,7 +35,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
function prepareItem(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
@@ -40,33 +52,74 @@ 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);
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
||||
return item;
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const items = read();
|
||||
const item = prepareItem(draft);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const item = enqueue(draft, false);
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = enqueue(draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const item = prepareItem(draft);
|
||||
const current = read();
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.reconcile([...current, item]);
|
||||
const localItem = localIndexItem(item);
|
||||
write([...current, item].map(localIndexItem), false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
return { item: localItem, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
const persisted = read().find(candidate => candidate.id === item.id);
|
||||
if (!persisted) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
async function hydrateForEdit(id) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return null;
|
||||
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
|
||||
if (!backgroundSync?.get) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const durable = await backgroundSync.get(id);
|
||||
const attachment = captureAttachment(durable?.attachment);
|
||||
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
}
|
||||
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
@@ -76,14 +129,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,24 +147,38 @@ 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;
|
||||
}), mirror);
|
||||
});
|
||||
return { items, updated };
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
const { items, updated } = prepareUpdate(id, draft);
|
||||
write(items, mirror);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateDurably(id, draft) {
|
||||
const item = update(id, draft, false);
|
||||
if (!item || !backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = update(id, draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const { items, updated: item } = prepareUpdate(id, draft);
|
||||
if (!item) return { item, background: false, durability: 'foreground-only' };
|
||||
try {
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.reconcile(items);
|
||||
const localItems = items.map(localIndexItem);
|
||||
write(localItems, false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
return { item: localIndexItem(item), background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
const persisted = read().find(candidate => candidate.id === id);
|
||||
if (!persisted || persisted.operationId !== item.operationId) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +189,88 @@ 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;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
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',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
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);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
try {
|
||||
@@ -135,29 +284,24 @@ 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);
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
}
|
||||
@@ -237,6 +381,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 +392,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),
|
||||
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [item];
|
||||
return [{ ...localIndexItem(item), ...deliveryStage }];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
@@ -276,7 +424,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+81
-1
@@ -20,6 +20,8 @@
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const passkeyButton = options.passkeyButton;
|
||||
const credentials = options.credentials;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||
@@ -28,6 +30,34 @@
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const bytes = new Uint8Array(value);
|
||||
let binary = '';
|
||||
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
function credentialJSON(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeBase64Url(credential.response.signature),
|
||||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
|
||||
async function showReason(reason) {
|
||||
@@ -35,6 +65,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…';
|
||||
@@ -91,18 +125,60 @@
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
return { submit, showReason };
|
||||
async function signInWithPasskey(deviceLabel = 'This device') {
|
||||
if (!credentials?.get) {
|
||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||
return false;
|
||||
}
|
||||
status.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
|
||||
method: 'POST', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: credentialJSON(credential),
|
||||
device_label: deviceLabel,
|
||||
action: 'sign_in',
|
||||
target: 'dashboard',
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Passkey verification failed');
|
||||
location.replace(continuation);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
|
||||
return { submit, signInWithPasskey, showReason };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const passkeyButton = document.getElementById('passkey-sign-in');
|
||||
const loginParams = new URLSearchParams(window.location.search);
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
passkeyButton,
|
||||
credentials: window.navigator?.credentials,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
continuation: loginParams.get('continue'),
|
||||
@@ -116,4 +192,8 @@ if (typeof document !== 'undefined') {
|
||||
const deviceLabel = data.get('device_label');
|
||||
controller.submit(accessToken, deviceLabel);
|
||||
});
|
||||
passkeyButton?.addEventListener('click', () => {
|
||||
const data = new FormData(form);
|
||||
controller.signInWithPasskey(data.get('device_label'));
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
active.workspace.scrollIntoView({ block:'end', inline:'nearest' });
|
||||
const focused = documentRef.activeElement;
|
||||
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
|
||||
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
@@ -59,12 +61,14 @@
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.composer.addEventListener('focus', entry.onFocus);
|
||||
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
|
||||
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
|
||||
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.composer.removeEventListener('focus', entry.onFocus);
|
||||
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
|
||||
+59
-20
@@ -292,6 +292,22 @@ function createNotificationReader({
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
@@ -327,6 +343,10 @@ function createNotificationReader({
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
@@ -354,19 +374,7 @@ function createNotificationReader({
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return await advanceAfterRead(items, current, queueing);
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
@@ -563,27 +571,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 +621,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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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-v84';
|
||||
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,29 @@ 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/update-reply-read-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 +60,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 +117,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 +184,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 +232,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));
|
||||
}
|
||||
|
||||
+320
-23
@@ -13,8 +13,10 @@
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
credentials: root.navigator?.credentials,
|
||||
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 +29,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');
|
||||
@@ -35,7 +38,14 @@
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
@@ -72,15 +82,128 @@
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
device_revoked: 'Device access revoked',
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [
|
||||
event.device_label,
|
||||
event.method,
|
||||
event.target,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||||
].filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
renderPasskeys();
|
||||
renderSecurityActivity();
|
||||
});
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
@@ -91,16 +214,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, credentials, 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 +243,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();
|
||||
}
|
||||
@@ -150,6 +285,33 @@
|
||||
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
let binary = '';
|
||||
new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
function authenticationCredentialJSON(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeBase64Url(credential.response.signature),
|
||||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSameOrigin(input) {
|
||||
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||||
catch (_error) { return false; }
|
||||
@@ -205,6 +367,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function authorizeWithPasskey(details) {
|
||||
if (!credentials?.get) return null;
|
||||
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' });
|
||||
const csrf = csrfToken();
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ action: details.action, target: details.target }),
|
||||
}, 'POST', 'passkey-authorization');
|
||||
if (!optionsResponse.ok) return null;
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: authenticationCredentialJSON(credential),
|
||||
action: details.action,
|
||||
target: details.target,
|
||||
}),
|
||||
}, 'POST', 'passkey-authorization');
|
||||
if (!verified.ok) return null;
|
||||
const payload = await verified.json().catch(() => ({}));
|
||||
return payload.grant || null;
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||
const requestOptions = { ...options };
|
||||
@@ -219,31 +412,37 @@
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
let grant = null;
|
||||
try { grant = await authorizeWithPasskey(detail); }
|
||||
catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
|
||||
if (!grant) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const grant = await authorization.json().catch(() => ({}));
|
||||
if (!grant.grant) return response;
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const authorizationPayload = await authorization.json().catch(() => ({}));
|
||||
grant = authorizationPayload.grant;
|
||||
}
|
||||
if (!grant) return response;
|
||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||||
retryHeaders.set('X-Step-Up-Grant', grant.grant);
|
||||
retryHeaders.set('X-Step-Up-Grant', grant);
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
@@ -253,6 +452,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 +463,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');
|
||||
@@ -336,6 +559,41 @@
|
||||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||||
}
|
||||
|
||||
async function enrollPasskey() {
|
||||
if (!credentials?.create) throw new Error('Passkeys are not supported');
|
||||
const optionsResponse = await sessionFetch(
|
||||
base + 'api/v1/passkeys/registration/options', { method: 'POST' }
|
||||
);
|
||||
if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment');
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.user.id = decodeBase64Url(publicKey.user.id);
|
||||
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.create({ publicKey });
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeBase64Url(credential.response.attestationObject),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports?.() || [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not verify passkey enrollment');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listActiveDevices() {
|
||||
const response = await sessionFetch(base + 'api/v1/sessions');
|
||||
if (!response.ok) throw new Error('Could not load active devices');
|
||||
@@ -343,6 +601,39 @@
|
||||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||||
}
|
||||
|
||||
async function listPasskeys() {
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys');
|
||||
if (!response.ok) throw new Error('Could not load enrolled passkeys');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
|
||||
}
|
||||
|
||||
async function revokePasskey(passkey) {
|
||||
if (!passkey?.management_id) return false;
|
||||
const confirmed = confirmAction?.(
|
||||
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not remove enrolled passkey');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
|
||||
if (!response.ok) throw new Error('Could not load security activity');
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeActiveDevice(device) {
|
||||
if (!device?.management_id || device.current) return false;
|
||||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||||
@@ -386,10 +677,16 @@
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -0,0 +1,50 @@
|
||||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admit(item, body, operationId, replyConfirmed) {
|
||||
const admission = await queue({
|
||||
kind: 'update-reply-read',
|
||||
notificationId: item.notification_id,
|
||||
body,
|
||||
operationId,
|
||||
replyConfirmed,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||
}
|
||||
accept(item);
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
next: await next(item),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
await markRead(item.notification_id);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
return await admit(item, body, operationId, replyConfirmed);
|
||||
}
|
||||
accept(item);
|
||||
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||
@@ -1,5 +1,8 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
rjsmin==1.2.5
|
||||
uvicorn==0.41.0
|
||||
webauthn==3.0.0
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
+42
-3
@@ -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,8 +97,23 @@ 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"
|
||||
now: int | None = None,
|
||||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
@@ -114,7 +130,10 @@ def issue_session(
|
||||
encoded = _encode(payload)
|
||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
_session_store(now).activate(
|
||||
session.session_id, session.expires_at, device_label=device_label
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
device_label=device_label,
|
||||
management_id=management_id,
|
||||
)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
@@ -146,7 +165,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 +193,19 @@ async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def session_management_id(session: Session) -> str:
|
||||
return await asyncio.to_thread(_session_store().management_id, 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)
|
||||
|
||||
|
||||
+10
-1
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import rjsmin
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
@@ -16,6 +19,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, ...]
|
||||
@@ -27,7 +31,10 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
for source in sources:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
return b"".join(chunks)
|
||||
source = b"".join(chunks)
|
||||
revision = hashlib.sha256(source).hexdigest()
|
||||
minified = rjsmin.jsmin(source.decode()).encode()
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
@@ -38,6 +45,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 +71,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,
|
||||
|
||||
+120
-2
@@ -301,6 +301,21 @@ async def repos() -> RepositoryItems:
|
||||
)
|
||||
|
||||
|
||||
async def search_repositories(query: str, limit: int = 20) -> list[dict]:
|
||||
"""Search repositories visible to the authenticated Gitea user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/repos/search",
|
||||
headers=_auth(),
|
||||
params={"q": query.strip(), "limit": limit, "page": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("Gitea repository search response was not a list")
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
|
||||
|
||||
async def repository_access(repository: str) -> dict | None:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
response = await _get_client().get(
|
||||
@@ -878,6 +893,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 +1171,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 +1419,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 +1463,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,
|
||||
|
||||
@@ -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()
|
||||
+1149
-75
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,282 @@
|
||||
"""Durable, one-time WebAuthn challenges and device-bound passkey credentials."""
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from src.session_store import SessionStoreError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredPasskey:
|
||||
credential_id: bytes
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
device_label: str
|
||||
management_id: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class PasskeyStore:
|
||||
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
|
||||
@staticmethod
|
||||
def _digest(value: bytes | str) -> str:
|
||||
raw = value if isinstance(value, bytes) else value.encode()
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=0.1)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_credentials (
|
||||
credential_id BLOB PRIMARY KEY,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL,
|
||||
device_label TEXT NOT NULL,
|
||||
management_id TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||
challenge_hash TEXT PRIMARY KEY,
|
||||
session_hash TEXT,
|
||||
purpose TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def issue_challenge(
|
||||
self,
|
||||
challenge: bytes,
|
||||
*,
|
||||
session_id: str | None,
|
||||
purpose: str,
|
||||
action: str,
|
||||
target: str,
|
||||
ttl_seconds: int = 120,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_challenges("
|
||||
"challenge_hash, session_hash, purpose, action, target, expires_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
self._digest(challenge),
|
||||
self._digest(session_id) if session_id else None,
|
||||
purpose,
|
||||
action,
|
||||
target,
|
||||
now + max(1, ttl_seconds),
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def consume_challenge(
|
||||
self,
|
||||
challenge: bytes,
|
||||
*,
|
||||
session_id: str | None,
|
||||
purpose: str,
|
||||
action: str,
|
||||
target: str,
|
||||
) -> bool:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_challenges WHERE challenge_hash = ? "
|
||||
"AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? "
|
||||
"AND expires_at > ?",
|
||||
(
|
||||
self._digest(challenge),
|
||||
self._digest(session_id) if session_id else None,
|
||||
purpose,
|
||||
action,
|
||||
target,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def register(
|
||||
self,
|
||||
*,
|
||||
credential_id: bytes,
|
||||
public_key: bytes,
|
||||
sign_count: int,
|
||||
device_label: str,
|
||||
management_id: str,
|
||||
) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
|
||||
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
credential_id,
|
||||
public_key,
|
||||
sign_count,
|
||||
device_label,
|
||||
management_id,
|
||||
int(self.clock()),
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def all(self) -> list[StoredPasskey]:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return [StoredPasskey(*row) for row in rows]
|
||||
|
||||
def get(self, credential_id: bytes) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE credential_id = ?",
|
||||
(credential_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def get_management_id(self, management_id: str) -> StoredPasskey | None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT credential_id, public_key, sign_count, device_label, management_id, created_at "
|
||||
"FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE passkey_credentials SET sign_count = ? "
|
||||
"WHERE credential_id = ? AND sign_count <= ?",
|
||||
(new_sign_count, credential_id, new_sign_count),
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_management_id(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_access(
|
||||
self, management_id: str, *, preserve_session: bool
|
||||
) -> tuple[bool, bool]:
|
||||
"""Atomically remove one passkey and, when requested, its active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
credential = connection.execute(
|
||||
"SELECT 1 FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if credential is None:
|
||||
return False, False
|
||||
session_revoked = False
|
||||
if not preserve_session:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is not None:
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
)
|
||||
session_revoked = cursor.rowcount == 1
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?",
|
||||
(management_id,),
|
||||
)
|
||||
return cursor.rowcount == 1, session_revoked
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all(self) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_device_access(self, management_id: str) -> bool:
|
||||
"""Atomically remove one active session, its grants, and its linked passkey."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
session = connection.execute(
|
||||
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
||||
(management_id,),
|
||||
).fetchone()
|
||||
if session is None:
|
||||
return False
|
||||
connection.execute(
|
||||
"DELETE FROM step_up_grants WHERE session_hash = ?", (session[0],)
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def revoke_all_access(self) -> None:
|
||||
"""Atomically remove every passkey, challenge, grant, and active session."""
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("DELETE FROM passkey_credentials")
|
||||
connection.execute("DELETE FROM passkey_challenges")
|
||||
connection.execute("DELETE FROM step_up_grants")
|
||||
connection.execute("DELETE FROM active_sessions")
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
@@ -0,0 +1,87 @@
|
||||
"""WebAuthn passkey ceremony helpers for the single dashboard operator."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
PublicKeyCredentialDescriptor,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
|
||||
def encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]:
|
||||
challenge = secrets.token_bytes(32)
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id,
|
||||
rp_name="Stackchain Dashboard",
|
||||
user_name="stackchain-operator",
|
||||
user_display_name="Stackchain operator",
|
||||
challenge=challenge,
|
||||
exclude_credentials=[
|
||||
PublicKeyCredentialDescriptor(id=credential_id)
|
||||
for credential_id in (excluded or [])
|
||||
],
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
),
|
||||
)
|
||||
return json.loads(options_to_json(options)), challenge
|
||||
|
||||
|
||||
def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]:
|
||||
challenge = secrets.token_bytes(32)
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id,
|
||||
challenge=challenge,
|
||||
allow_credentials=[
|
||||
PublicKeyCredentialDescriptor(id=credential_id)
|
||||
for credential_id in credentials
|
||||
],
|
||||
user_verification=UserVerificationRequirement.REQUIRED,
|
||||
)
|
||||
return json.loads(options_to_json(options)), challenge
|
||||
|
||||
|
||||
def verify_registration(
|
||||
*, credential: dict, challenge: bytes, rp_id: str, origin: str
|
||||
):
|
||||
return verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=origin,
|
||||
require_user_verification=True,
|
||||
)
|
||||
|
||||
|
||||
def verify_authentication(
|
||||
*, credential: dict, challenge: bytes, rp_id: str, origin: str, stored
|
||||
):
|
||||
return verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id,
|
||||
expected_origin=origin,
|
||||
credential_public_key=stored.public_key,
|
||||
credential_current_sign_count=stored.sign_count,
|
||||
require_user_verification=True,
|
||||
)
|
||||
@@ -5,6 +5,8 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
SESSION_BODY_LIMIT = 16 * 1024
|
||||
API_MUTATION_BODY_LIMIT = 64 * 1024
|
||||
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
|
||||
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
|
||||
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
@@ -13,6 +15,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
|
||||
@@ -42,6 +51,11 @@ class RequestBodyLimitMiddleware:
|
||||
if limit is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
if (
|
||||
limit == ISSUE_ATTACHMENT_BODY_LIMIT
|
||||
and self._header(scope, b"content-type").startswith(b"application/json")
|
||||
):
|
||||
limit = LEGACY_JSON_ATTACHMENT_BODY_LIMIT
|
||||
|
||||
declared_length = self._content_length(scope)
|
||||
if declared_length is not None and declared_length > limit:
|
||||
@@ -69,6 +83,13 @@ class RequestBodyLimitMiddleware:
|
||||
|
||||
await self.app(scope, replay, send)
|
||||
|
||||
@staticmethod
|
||||
def _header(scope: Scope, wanted: bytes) -> bytes:
|
||||
for name, value in scope.get("headers", []):
|
||||
if name.lower() == wanted:
|
||||
return value.lower()
|
||||
return b""
|
||||
|
||||
@staticmethod
|
||||
def _content_length(scope: Scope) -> int | None:
|
||||
for name, value in scope.get("headers", []):
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Bounded, privacy-preserving journal of operator security activity."""
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class SecurityEventStoreError(RuntimeError):
|
||||
"""Raised when security activity cannot be persisted or read safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecurityEvent:
|
||||
id: int
|
||||
kind: str
|
||||
method: str | None
|
||||
device_label: str | None
|
||||
target: str | None
|
||||
created_at: int
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecurityEventPage:
|
||||
events: list[SecurityEvent]
|
||||
next_cursor: int | None
|
||||
|
||||
|
||||
class SecurityEventStore:
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
clock: Callable[[], float],
|
||||
max_events: int = 10_000,
|
||||
retention_seconds: int = 90 * 24 * 60 * 60,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
self.max_events = max(1, max_events)
|
||||
self.retention_seconds = max(1, retention_seconds)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
|
||||
@staticmethod
|
||||
def _bounded(value: str | None, limit: int) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = " ".join(str(value).split())[:limit]
|
||||
return normalized or None
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(self.path, timeout=self.lock_timeout_seconds)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS security_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
method TEXT,
|
||||
device_label TEXT,
|
||||
target TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
operation_id TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
if "status" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
|
||||
)
|
||||
if "operation_id" not in columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE security_events ADD COLUMN operation_id TEXT"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS security_events_created "
|
||||
"ON security_events(created_at DESC, id DESC)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS security_events_operation "
|
||||
"ON security_events(operation_id) WHERE operation_id IS NOT NULL"
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def _prune(self, connection: sqlite3.Connection, now: int) -> None:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE created_at < ?",
|
||||
(now - self.retention_seconds,),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE id NOT IN "
|
||||
"(SELECT id FROM security_events ORDER BY id DESC LIMIT ?)",
|
||||
(self.max_events,),
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> None:
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'completed')",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def reserve(
|
||||
self,
|
||||
kind: str,
|
||||
*,
|
||||
method: str | None = None,
|
||||
device_label: str | None = None,
|
||||
target: str | None = None,
|
||||
) -> str:
|
||||
now = int(self.clock())
|
||||
operation_id = secrets.token_urlsafe(24)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO security_events(kind, method, device_label, target, created_at, status, operation_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, 'pending', ?)",
|
||||
(
|
||||
self._bounded(kind, 48) or "security_event",
|
||||
self._bounded(method, 32),
|
||||
self._bounded(device_label, 64),
|
||||
self._bounded(target, 255),
|
||||
now,
|
||||
operation_id,
|
||||
),
|
||||
)
|
||||
self._prune(connection, now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
return operation_id
|
||||
|
||||
def finalize(self, operation_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"UPDATE security_events SET status = 'completed' WHERE operation_id = ?",
|
||||
(operation_id,),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity reservation was not found"
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def discard(self, operation_id: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM security_events WHERE operation_id = ? AND status = 'pending'",
|
||||
(operation_id,),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list(self, *, limit: int = 50, cursor: int | None = None) -> SecurityEventPage:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
parameters: list[int] = []
|
||||
where = ""
|
||||
if cursor is not None:
|
||||
where = "WHERE id < ?"
|
||||
parameters.append(cursor)
|
||||
parameters.append(bounded_limit + 1)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, kind, method, device_label, target, created_at, status "
|
||||
f"FROM security_events {where} ORDER BY id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SecurityEventStoreError(
|
||||
"Security activity is temporarily unavailable"
|
||||
) from exc
|
||||
has_more = len(rows) > bounded_limit
|
||||
visible = rows[:bounded_limit]
|
||||
return SecurityEventPage(
|
||||
events=[SecurityEvent(*row) for row in visible],
|
||||
next_cursor=visible[-1][0] if has_more else None,
|
||||
)
|
||||
+79
-5
@@ -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)"
|
||||
@@ -111,7 +117,12 @@ class SessionStore:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
|
||||
def activate(
|
||||
self, session_id: str, expires_at: int, *, device_label: str = "This device"
|
||||
self,
|
||||
session_id: str,
|
||||
expires_at: int,
|
||||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
) -> None:
|
||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||
now = int(self.clock())
|
||||
@@ -122,14 +133,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),
|
||||
management_id or secrets.token_urlsafe(18),
|
||||
label,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
@@ -147,6 +159,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:
|
||||
@@ -185,6 +246,19 @@ class SessionStore:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def management_id(self, session_id: str) -> str:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT management_id FROM active_sessions WHERE session_hash = ?",
|
||||
(self._digest(session_id),),
|
||||
).fetchone()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||
if row is None:
|
||||
raise SessionStoreError("Session is no longer active")
|
||||
return row[0]
|
||||
|
||||
def revoke_managed(self, management_id: str) -> bool:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+67
-17
@@ -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,
|
||||
|
||||
+16
-6
@@ -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"
|
||||
@@ -14,8 +15,8 @@ LOGIN_HTML = """<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<body><main><h1>Operator sign in</h1><p>Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
@@ -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 {}),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
|
||||
def test_global_access_revocation_rolls_back_every_credential_and_session_change(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
sessions.activate(
|
||||
"phone-session",
|
||||
2_000,
|
||||
management_id="phone-management-id",
|
||||
device_label="Phone",
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
"phone-session",
|
||||
action="revoke_all_sessions",
|
||||
target="all",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
passkeys.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
passkeys.issue_challenge(
|
||||
b"pending-challenge",
|
||||
session_id="phone-session",
|
||||
purpose="authentication",
|
||||
action="sign_in",
|
||||
target="dashboard",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_session_delete BEFORE DELETE ON active_sessions "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_all_access()
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_credentials").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM passkey_challenges").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (1,)
|
||||
assert connection.execute("SELECT COUNT(*) FROM active_sessions").fetchone() == (1,)
|
||||
|
||||
|
||||
def test_device_access_revocation_rolls_back_target_and_preserves_other_devices(tmp_path):
|
||||
database = tmp_path / "sessions.sqlite3"
|
||||
sessions = SessionStore(database, clock=lambda: 1_000.0)
|
||||
passkeys = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
for label in ("Phone", "Laptop"):
|
||||
slug = label.lower()
|
||||
sessions.activate(
|
||||
f"{slug}-session",
|
||||
2_000,
|
||||
management_id=f"{slug}-management-id",
|
||||
device_label=label,
|
||||
)
|
||||
sessions.mint_step_up(
|
||||
f"{slug}-session",
|
||||
action="close_issue",
|
||||
target="stackchain/dashboard#1",
|
||||
ttl_seconds=90,
|
||||
)
|
||||
passkeys.register(
|
||||
credential_id=f"{slug}-credential".encode(),
|
||||
public_key=f"{slug}-public-key".encode(),
|
||||
sign_count=0,
|
||||
device_label=label,
|
||||
management_id=f"{slug}-management-id",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_phone_delete BEFORE DELETE ON active_sessions "
|
||||
"WHEN OLD.management_id = 'phone-management-id' "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
with pytest.raises(SessionStoreError):
|
||||
passkeys.revoke_device_access("phone-management-id")
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM active_sessions ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute(
|
||||
"SELECT management_id FROM passkey_credentials ORDER BY management_id"
|
||||
).fetchall() == [("laptop-management-id",), ("phone-management-id",)]
|
||||
assert connection.execute("SELECT COUNT(*) FROM step_up_grants").fetchone() == (2,)
|
||||
@@ -77,6 +77,156 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
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)}};
|
||||
let release; const gate = new Promise(resolve => release = resolve);
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', now:()=>777,
|
||||
fetchJson:async()=>{{await gate; const error=new Error('Network unavailable');error.status=503;throw error;}},
|
||||
}});
|
||||
outbox.enqueue({{kind:'issue-comment',repository:'o/r',number:1,body:'Wait',operationId:'attempt'}});
|
||||
const pending=outbox.flush('timmy');
|
||||
const sending=outbox.list()[0];
|
||||
release();
|
||||
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["sending"]["status"] == "sending"
|
||||
assert output["after"]["status"] == "queued"
|
||||
assert output["after"]["lastAttemptAt"] == 777
|
||||
assert output["after"]["lastAttemptError"] == "Network unavailable"
|
||||
|
||||
|
||||
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_requires_explicit_retry_to_authorize_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'
|
||||
}});
|
||||
(async()=>{{
|
||||
const automatic = await outbox.flush('timmy');
|
||||
const awaitingAuthorization = outbox.list();
|
||||
const explicit = await outbox.retry(queued.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
queued,automatic,awaitingAuthorization,explicit,calls,remaining:outbox.list()
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["queued"]["kind"] == "issue-close"
|
||||
assert output["automatic"]["confirmed"] == []
|
||||
assert output["awaitingAuthorization"][0]["status"] == "queued"
|
||||
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["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_queued_closure_stays_durable_through_background_skip_then_closes_from_explicit_retry():
|
||||
sync = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
script = f"""
|
||||
const createAuthoredOutbox=require({json.dumps(str(OUTBOX))});
|
||||
const createBackgroundIssueSync=require({json.dumps(str(sync))});
|
||||
const values=new Map();const records=new Map();let tail=Promise.resolve();const calls=[];
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),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;}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'] || ''}});
|
||||
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{number:27,state:'closed'}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const background=createBackgroundIssueSync({{store,fetchJson}});background.requestSync=async()=>{{}};
|
||||
const outbox=createAuthoredOutbox({{storage,fetchJson,getOwnerLogin:()=>'timmy',backgroundSync:background}});
|
||||
const admitted=await outbox.enqueueDurably({{kind:'issue-close',repository:'stackchain/dashboard',number:27,operationId:'close-op'}});
|
||||
const automatic=await background.flush();
|
||||
outbox.reconcileBackground(await background.snapshot());
|
||||
const foregroundFlush=await outbox.flush('timmy');
|
||||
const explicit=await outbox.retry(admitted.item.id,'timmy');
|
||||
process.stdout.write(JSON.stringify({{automatic,foregroundFlush,explicit,calls,remaining:outbox.list(),snapshot:await background.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["automatic"]["confirmed"] == []
|
||||
assert output["foregroundFlush"]["confirmed"] == []
|
||||
assert output["calls"] == [
|
||||
{"url": "api/v1/background-identity", "key": ""},
|
||||
{"url": "api/v1/repos/stackchain/dashboard/issues/27/close", "key": "close-op"},
|
||||
]
|
||||
assert output["explicit"]["confirmed"] == [{"number": 27, "state": "closed"}]
|
||||
assert output["remaining"] == []
|
||||
assert output["snapshot"][0]["status"] == "sent"
|
||||
|
||||
|
||||
def test_authored_outbox_classifies_failures_and_continues_past_attention_items():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -183,6 +333,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))});
|
||||
@@ -300,3 +496,56 @@ async def test_mobile_dashboard_loads_and_operates_authored_message_outbox():
|
||||
assert "if (result?.queued)" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "authoredOutbox.reconcileBackground(records)" in html
|
||||
|
||||
|
||||
def test_screenshot_comment_admits_blob_to_indexeddb_without_putting_bytes_in_localstorage():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors=[];
|
||||
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',
|
||||
backgroundSync:{{reconcile:async(items,lane)=>mirrors.push({{items,lane}}),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const blob=new Blob(['PRIVATE-IMAGE-BYTES'],{{type:'image/png'}});
|
||||
const admission=await outbox.enqueueDurably({{
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:477,
|
||||
body:'Broken at 320px',operationId:'comment-image-477',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}},
|
||||
}});
|
||||
const durable=mirrors[0].items[0].attachment;
|
||||
process.stdout.write(JSON.stringify({{admission,local:values.get('stackchain.authored-outbox.v1'),
|
||||
mirrored:{{lane:mirrors[0].lane,isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}}}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "PRIVATE-IMAGE-BYTES" not in output["local"]
|
||||
local_item = json.loads(output["local"])["items"][0]
|
||||
assert local_item["attachment"] == {
|
||||
"filename": "phone.png", "contentType": "image/png", "stored": True
|
||||
}
|
||||
assert output["mirrored"] == {
|
||||
"lane": "authored", "isBlob": True, "size": 19, "text": "PRIVATE-IMAGE-BYTES"
|
||||
}
|
||||
assert output["admission"]["durability"] == "background"
|
||||
|
||||
|
||||
def test_screenshot_comment_failed_indexeddb_admission_is_not_accepted_locally():
|
||||
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 outbox=createAuthoredOutbox({{storage,getOwnerLogin:()=>'timmy',backgroundSync:{{
|
||||
reconcile:async()=>{{throw new Error('IndexedDB unavailable');}},requestSync:async()=>{{}},
|
||||
}}}});
|
||||
(async()=>{{
|
||||
let error='';try{{await outbox.enqueueDurably({{kind:'issue-comment',repository:'o/r',number:1,
|
||||
body:'Keep me',operationId:'image-op',attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}});}}
|
||||
catch(caught){{error=caught.message;}}
|
||||
process.stdout.write(JSON.stringify({{error,items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {"error": "IndexedDB unavailable", "items": []}
|
||||
@@ -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
|
||||
@@ -64,6 +64,179 @@ 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={{}})=>{{
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],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:''}};
|
||||
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": ""
|
||||
}
|
||||
assert output["state"]["completed"] == 1
|
||||
assert output["second"]["confirmed"][0]["number"] == 469
|
||||
|
||||
|
||||
def test_closed_app_sync_uploads_blob_as_multipart_and_preserves_original_bytes():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const blob=new Blob(['offline-binary'],{{type:'image/png'}});
|
||||
const item={{id:'binary',operationId:'binary',ownerLogin:'timmy',repository:'o/r',title:'Visual',body:'',labelIds:[],
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',blob}}}};
|
||||
let queued=true;
|
||||
const observed={{}};
|
||||
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(url.endsWith('/issues'))return{{number:7}};
|
||||
if(url.endsWith('/attachments')){{const file=options.body.get('file');Object.assign(observed,{{
|
||||
isFormData:options.body instanceof FormData,contentTypeHeader:options.headers['Content-Type']||null,
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}});return{{markdown:''}};}}
|
||||
return{{id:8}};
|
||||
}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(observed));}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"isFormData": True,
|
||||
"contentTypeHeader": None,
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "offline-binary",
|
||||
}
|
||||
|
||||
|
||||
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:''}};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_capture_renews_claim_before_every_network_stage_and_fences_completion():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
const item={{id:'capture',claimToken:'claim-7',operationId:'capture',ownerLogin:'timmy',repository:'o/r',title:'Bug',body:'',labelIds:[],attachment:{{filename:'a.png',contentType:'image/png',data:'abc'}}}};
|
||||
const events=[];let claimed=false;
|
||||
const store={{
|
||||
supportsClaimTokens:true,
|
||||
claimNext:async()=>claimed?null:(claimed=true,item),
|
||||
renew:async(id,token)=>{{events.push(['renew',id,token]);return item;}},
|
||||
checkpoint:async(id,token,transform)=>{{events.push(['checkpoint',id,token]);transform(item);return true;}},
|
||||
complete:async(id,token)=>events.push(['complete',id,token]),
|
||||
release:async(id,token)=>events.push(['release',id,token]),
|
||||
fail:async()=>{{}},countBlocked:async()=>0,
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
events.push(['network',options.headers['Idempotency-Key']]);
|
||||
if(url.endsWith('/issues'))return{{number:3}};
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
return{{id:4}};
|
||||
}};
|
||||
(async()=>{{await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(events));}})();
|
||||
"""
|
||||
events = run_node(script)
|
||||
|
||||
networks = [index for index, event in enumerate(events) if event[0] == "network"]
|
||||
assert [events[index - 1][0] for index in networks] == ["renew", "renew", "renew"]
|
||||
assert [events[index][1] for index in networks] == [
|
||||
"capture", "capture:attachment", "capture:attachment-comment"
|
||||
]
|
||||
assert events[-1] == ["complete", "capture", "claim-7"]
|
||||
assert all(event[2] == "claim-7" for event in events if event[0] in {"renew", "checkpoint"})
|
||||
|
||||
|
||||
def test_screenshot_comment_retry_reuses_upload_checkpoint_and_posts_combined_body_once():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'message-image',operationId:'message-image',ownerLogin:'timmy',status:'queued',
|
||||
kind:'issue-comment',repository:'stackchain/dashboard',number:477,body:'Broken at 320px',
|
||||
attachment:{{filename:'phone.png',contentType:'image/png',data:'iVBORw0KGgo='}}}};
|
||||
const state={{calls:[],released:0,completed:0}};let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{state.completed++;item=null;}},release:async()=>{{state.released++;item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
let body=null;
|
||||
if(options.body instanceof FormData){{const file=options.body.get('file');body={{filename:file.name,multipart:true}};}}
|
||||
else if(options.body)body=JSON.parse(options.body);
|
||||
state.calls.push({{url,key:options.headers?.['Idempotency-Key'],body}});
|
||||
if(url.endsWith('/attachments'))return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('Comment unavailable');error.status=503;throw error;}}
|
||||
return{{id:91}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let firstError='';
|
||||
try{{await sync.flush();}}catch(error){{firstError=error.message;}}
|
||||
const checkpoint={{...item}};const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,firstError,checkpoint,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["firstError"] == "Comment unavailable"
|
||||
assert output["checkpoint"]["attachmentMarkdown"].startswith("![phone.png]")
|
||||
assert sum(call["url"].endswith("/attachments") for call in output["state"]["calls"]) == 1
|
||||
comments = [call for call in output["state"]["calls"] if call["url"].endswith("/comments")]
|
||||
assert [call["key"] for call in comments] == ["message-image:comment"] * 2
|
||||
assert comments[-1]["body"] == {
|
||||
"body": "Broken at 320px\n\n"
|
||||
}
|
||||
assert output["state"]["completed"] == 1
|
||||
assert output["second"]["confirmed"] == [{"id": 91}]
|
||||
|
||||
|
||||
def test_closed_app_sync_retains_created_issue_for_create_and_start_recovery():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -203,6 +376,79 @@ 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_leaves_issue_closure_awaiting_foreground_authorization():
|
||||
records = [
|
||||
{
|
||||
"id": "close-op", "operationId": "close-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-close", "repository": "stackchain/dashboard", "number": 27, "body": "",
|
||||
},
|
||||
{
|
||||
"id": "comment-op", "operationId": "comment-op", "ownerLogin": "timmy", "status": "queued",
|
||||
"kind": "issue-comment", "repository": "stackchain/dashboard", "number": 27, "body": "Still deliver",
|
||||
},
|
||||
]
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map({json.dumps([[item["id"], item] for item in records])});let tail=Promise.resolve();const calls=[];
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),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;}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
calls.push({{url,options}});
|
||||
return url==='api/v1/background-identity'?{{login:'timmy'}}:{{id:45}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{calls,result,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
mutations = output["calls"][1:]
|
||||
assert [call["url"] for call in mutations] == [
|
||||
"api/v1/repos/stackchain/dashboard/issues/27/comments"
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"id": 45}]
|
||||
by_id = {item["id"]: item for item in output["snapshot"]}
|
||||
assert by_id["close-op"]["status"] == "queued"
|
||||
assert by_id["comment-op"]["status"] == "sent"
|
||||
|
||||
|
||||
def test_reconciling_one_outbox_lane_preserves_the_other_lane():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -226,6 +472,81 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
]
|
||||
|
||||
|
||||
def test_reconciling_a_lightweight_attachment_reference_preserves_indexeddb_blob():
|
||||
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.reconcile([{{
|
||||
id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',blob:new Blob(['first-image-bytes'],{{type:'image/png'}})}},
|
||||
}}]);
|
||||
await store.reconcile([
|
||||
{{id:'first',operationId:'first',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'one.png',contentType:'image/png',stored:true}}}},
|
||||
{{id:'second',operationId:'second',ownerLogin:'timmy',status:'queued',
|
||||
attachment:{{filename:'two.png',contentType:'image/png',data:'c2Vjb25kLWltYWdlLWJ5dGVz'}}}},
|
||||
]);
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{first:{{isBlob:snapshot[0].attachment.blob instanceof Blob,
|
||||
text:await snapshot[0].attachment.blob?.text()}},second:snapshot[1].attachment.data}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == {"isBlob": True, "text": "first-image-bytes"}
|
||||
assert output["second"] == "c2Vjb25kLWltYWdlLWJ5dGVz"
|
||||
|
||||
|
||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map([
|
||||
['first',{{id:'first',attachment:{{filename:'one.png',contentType:'image/png',data:'first-bytes'}}}}],
|
||||
['second',{{id:'second',attachment:{{filename:'two.png',contentType:'image/png',data:'second-bytes'}}}}],
|
||||
]);
|
||||
const calls=[];
|
||||
const transaction=work=>work({{
|
||||
get:async id=>{{calls.push(['get',id]);return records.get(id);}},
|
||||
getAll:async()=>{{calls.push(['getAll']);return [...records.values()];}},
|
||||
put:async()=>{{}},delete:async()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
const hydrated=await store.get('second');
|
||||
process.stdout.write(JSON.stringify({{hydrated,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["hydrated"]["attachment"]["data"] == "second-bytes"
|
||||
assert output["calls"] == [["get", "second"]]
|
||||
|
||||
|
||||
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:''}}));
|
||||
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"] == ""
|
||||
|
||||
|
||||
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -520,6 +841,52 @@ const fetchJson = async (url, options={{}}) => {{
|
||||
assert output["error"] == "Temporary outage"
|
||||
|
||||
|
||||
def test_bounded_flush_claims_just_in_time_and_keeps_unadmitted_records_queued():
|
||||
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({{
|
||||
get:async id=>records.get(id),
|
||||
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;}};
|
||||
let allow=false;const waiting=[];const attempted=[];
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
attempted.push(options.headers['Idempotency-Key']);
|
||||
if(!allow)await new Promise(resolve=>waiting.push(resolve));
|
||||
return{{number:attempted.length}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,createToken:(()=>{{let n=0;return()=>`token-${{++n}}`;}})()}});
|
||||
await store.reconcile(Array.from({{length:4}},(_,i)=>({{id:`issue-${{i}}`,operationId:`issue-${{i}}`,ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Issue',labelIds:[]}})),'issue');
|
||||
await store.reconcile(Array.from({{length:2}},(_,i)=>({{id:`message-${{i}}`,operationId:`message-${{i}}`,ownerLogin:'timmy',status:'queued',kind:'issue-comment',repository:'o/r',number:i,body:'Reply'}})),'authored');
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2,batchSize:5}});
|
||||
const flushing=sync.flush();
|
||||
while(waiting.length<2)await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const during=await store.snapshot();
|
||||
allow=true;waiting.splice(0).forEach(resolve=>resolve());
|
||||
await flushing;
|
||||
const after=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{attempted,during,after}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["during"] if item["status"] == "sending"] == [
|
||||
"issue-0", "message-0"
|
||||
]
|
||||
assert [item["id"] for item in output["during"] if item["status"] == "queued"] == [
|
||||
"issue-1", "issue-2", "issue-3", "message-1"
|
||||
]
|
||||
assert output["attempted"] == [
|
||||
"issue-0", "message-0", "issue-1", "message-1", "issue-2"
|
||||
]
|
||||
unadmitted = next(item for item in output["after"] if item["id"] == "issue-3")
|
||||
assert unadmitted["status"] == "queued"
|
||||
assert "claimToken" not in unadmitted
|
||||
|
||||
|
||||
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -574,18 +941,15 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
|
||||
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
|
||||
], 'authored');
|
||||
const claimed=await store.claimBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
|
||||
const planned=await store.planBatch('timmy', 4);
|
||||
process.stdout.write(JSON.stringify({{planned,snapshot:await store.snapshot()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert [item["id"] for item in output["claimed"]] == [
|
||||
"issue-1", "message-1", "issue-2", "message-2"
|
||||
]
|
||||
assert all(item["status"] == "sending" for item in output["claimed"])
|
||||
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
|
||||
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
|
||||
assert output["planned"] == ["issue-1", "message-1", "issue-2", "message-2"]
|
||||
assert all(item["status"] == "queued" for item in output["snapshot"])
|
||||
assert all("claimUntil" not in item for item in output["snapshot"])
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
@@ -670,6 +1034,47 @@ const transaction = work => {{
|
||||
assert output["records"][0]["claimUntil"] == 6000
|
||||
|
||||
|
||||
def test_issue_store_renews_and_fences_claim_mutations_with_unique_tokens():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();let timestamp=100;
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
get:async id=>records.get(id),
|
||||
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 tokens=['claim-a','claim-b'];
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{
|
||||
transaction,now:()=>timestamp,claimMs:30,createToken:()=>tokens.shift(),
|
||||
}});
|
||||
await store.reconcile([{{id:'lease',ownerLogin:'timmy',status:'queued'}}]);
|
||||
const first=await store.claimNext('timmy');
|
||||
timestamp=120;
|
||||
const renewed=await store.renew('lease',first.claimToken);
|
||||
timestamp=151;
|
||||
const second=await store.claimNext('timmy');
|
||||
const staleComplete=await store.complete('lease',first.claimToken);
|
||||
const staleRelease=await store.release('lease',first.claimToken);
|
||||
const staleFail=await store.fail('lease',first.claimToken,'old failure');
|
||||
const currentComplete=await store.complete('lease',second.claimToken,{{number:9}});
|
||||
process.stdout.write(JSON.stringify({{first,renewed,second,staleComplete,staleRelease,staleFail,currentComplete,record:records.get('lease')}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"]["claimToken"] == "claim-a"
|
||||
assert output["first"]["claimUntil"] == 130
|
||||
assert output["renewed"]["claimUntil"] == 150
|
||||
assert output["second"]["claimToken"] == "claim-b"
|
||||
assert output["second"]["claimUntil"] == 181
|
||||
assert output["staleComplete"] is False
|
||||
assert output["staleRelease"] is False
|
||||
assert output["staleFail"] is False
|
||||
assert output["currentComplete"] is True
|
||||
assert output["record"]["status"] == "sent"
|
||||
|
||||
|
||||
def test_foreground_and_worker_race_still_posts_one_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
@@ -740,8 +1145,8 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
|
||||
await store.claimNext('timmy');
|
||||
await store.complete('done');
|
||||
const claimed=await store.claimNext('timmy');
|
||||
await store.complete('done', claimed.claimToken);
|
||||
const replay=await store.claimNext('timmy');
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{replay,snapshot}}));
|
||||
@@ -778,6 +1183,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
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
def test_comment_and_next_can_admit_a_complete_screenshot_message_before_advancing():
|
||||
script = f"""
|
||||
const createCommentNext=require({json.dumps(str(COMMENT_NEXT))});
|
||||
const calls=[];
|
||||
const controller=createCommentNext({{
|
||||
post:()=>{{throw new Error('must not post');}},canQueue:()=>true,
|
||||
queue:async message=>{{calls.push({{queue:message}});return{{item:{{id:'queued'}},background:true}};}},
|
||||
accept:item=>calls.push({{accept:item.number}}),
|
||||
complete:item=>{{calls.push({{complete:item.number}});return true;}},
|
||||
}});
|
||||
(async()=>{{const item={{kind:'issue',repository:'stackchain/dashboard',number:477}};
|
||||
const result=await controller.admit(item,{{kind:'issue-comment',repository:item.repository,number:item.number,
|
||||
body:'Mobile evidence',operationId:'image-op',attachment:{{filename:'phone.png',contentType:'image/png',data:'abc'}}}});
|
||||
process.stdout.write(JSON.stringify({{result,calls}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output["result"] == {
|
||||
"accepted": True, "delivery": "queued", "background": True, "completed": True
|
||||
}
|
||||
assert output["calls"][0]["queue"]["attachment"]["data"] == "abc"
|
||||
assert output["calls"][1:] == [{"accept": 477}, {"complete": 477}]
|
||||
|
||||
|
||||
@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 & 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_unread_update_offers_reply_mark_read_and_next_independent_of_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "const updateReplyReadNext = createUpdateReplyReadNext({" in html
|
||||
assert "markRead: markNotificationRead" in html
|
||||
assert "next: item => notificationReader.acceptReadAndNext(lastMyWork, item)" 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-v84" in worker
|
||||
@@ -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))});
|
||||
|
||||
+597
-18
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -16,6 +17,7 @@ def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security-events.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login-attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_MAX_FAILURES", "3")
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60")
|
||||
@@ -38,6 +40,496 @@ async def fresh_grant(client, action: str, target: str) -> str:
|
||||
return response.json()["grant"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_enrollment_options_require_fresh_authorization_and_are_one_time(
|
||||
access_control,
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Timmy's phone",
|
||||
},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing = await client.post("/api/v1/passkeys/registration/options", headers=headers)
|
||||
grant = await fresh_grant(client, "enroll_passkey", "current_device")
|
||||
created = await client.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
|
||||
assert missing.status_code == 428
|
||||
assert missing.json()["detail"]["action"] == "enroll_passkey"
|
||||
assert created.status_code == 201
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
options = created.json()
|
||||
assert options["rp"] == {"id": "test", "name": "Stackchain Dashboard"}
|
||||
assert options["user"]["name"] == "stackchain-operator"
|
||||
assert options["authenticatorSelection"]["userVerification"] == "required"
|
||||
assert options["challenge"]
|
||||
assert replayed.status_code == 428
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
class VerifiedAuthentication:
|
||||
new_sign_count = 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_registration",
|
||||
lambda **_kwargs: VerifiedRegistration(),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: VerifiedAuthentication(),
|
||||
raising=False,
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as bootstrap:
|
||||
await bootstrap.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
csrf_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": bootstrap.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await fresh_grant(bootstrap, "enroll_passkey", "current_device")
|
||||
options = await bootstrap.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
enrolled = await bootstrap.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as returning:
|
||||
sign_in_options = await returning.post("/api/v1/passkeys/authentication/options")
|
||||
signed_in = await returning.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": sign_in_options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
activity = await returning.get("/api/v1/security-events")
|
||||
|
||||
assert enrolled.status_code == 201
|
||||
assert enrolled.json() == {"enrolled": True}
|
||||
assert sign_in_options.status_code == 200
|
||||
assert sign_in_options.json()["allowCredentials"][0]["id"] == "cGhvbmUtY3JlZGVudGlhbA"
|
||||
assert signed_in.status_code == 200
|
||||
assert signed_in.json() == {"authenticated": True, "method": "passkey"}
|
||||
assert activity.json()["events"][0] == {
|
||||
"id": activity.json()["events"][0]["id"],
|
||||
"kind": "sign_in",
|
||||
"method": "passkey",
|
||||
"device_label": "Phone",
|
||||
"target": "dashboard",
|
||||
"created_at": activity.json()["events"][0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
assert "stackchain_session=" in signed_in.headers["set-cookie"]
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
class VerifiedAuthentication:
|
||||
new_sign_count = 1
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_authentication", lambda **_kwargs: VerifiedAuthentication()
|
||||
)
|
||||
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", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
enrollment_grant = await fresh_grant(client, "enroll_passkey", "current_device")
|
||||
registration = await client.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**headers, "X-Step-Up-Grant": enrollment_grant},
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=headers,
|
||||
)
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={"action": "close_issue", "target": "stackchain/api#7"},
|
||||
headers=headers,
|
||||
)
|
||||
ceremony = {
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
authorized = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify", json=ceremony, headers=headers
|
||||
)
|
||||
replayed = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify", json=ceremony, headers=headers
|
||||
)
|
||||
wrong_target = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify",
|
||||
json={**ceremony, "target": "stackchain/api#8"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert options.status_code == 200
|
||||
assert authorized.status_code == 201
|
||||
assert authorized.json()["grant"]
|
||||
assert authorized.json()["expires_in"] == 90
|
||||
assert replayed.status_code == 409
|
||||
assert wrong_target.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoking_an_enrolled_device_also_revokes_its_passkey(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"phone-credential"
|
||||
credential_public_key = b"credential-public-key"
|
||||
sign_count = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
phone_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
}
|
||||
enrollment_grant = await fresh_grant(phone, "enroll_passkey", "current_device")
|
||||
registration = await phone.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**phone_headers, "X-Step-Up-Grant": enrollment_grant},
|
||||
)
|
||||
await phone.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=phone_headers,
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
phone_device = next(item for item in devices if item["device_label"] == "Phone")
|
||||
grant = await fresh_grant(laptop, "revoke_device", phone_device["management_id"])
|
||||
revoked = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
passkey_options = await laptop.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert revoked.status_code == 200
|
||||
assert passkey_options.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_device_revocation_failure_preserves_its_passkey_and_session(
|
||||
access_control,
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
phone_device = next(
|
||||
item
|
||||
for item in (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
if item["device_label"] == "Phone"
|
||||
)
|
||||
store = main._passkey_store()
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=phone_device["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_device", phone_device["management_id"]
|
||||
)
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_phone_delete BEFORE DELETE ON active_sessions "
|
||||
f"WHEN OLD.management_id = '{phone_device['management_id']}' "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
response = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_session = await phone.get("/api/v1/session")
|
||||
passkeys = await laptop.get("/api/v1/passkeys")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert phone_session.status_code == 200
|
||||
assert phone_session.json()["authenticated"] is True
|
||||
assert [item["device_label"] for item in passkeys.json()["passkeys"]] == ["Phone"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orphaned_passkey_is_listed_safely_and_can_be_selectively_revoked(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class VerifiedRegistration:
|
||||
credential_id = b"old-phone-credential"
|
||||
credential_public_key = b"old-phone-public-key"
|
||||
sign_count = 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as phone:
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Old phone"},
|
||||
)
|
||||
phone_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
|
||||
}
|
||||
enrollment_grant = await fresh_grant(phone, "enroll_passkey", "current_device")
|
||||
registration = await phone.post(
|
||||
"/api/v1/passkeys/registration/options",
|
||||
headers={**phone_headers, "X-Step-Up-Grant": enrollment_grant},
|
||||
)
|
||||
enrolled = await phone.post(
|
||||
"/api/v1/passkeys/registration/verify",
|
||||
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
|
||||
headers=phone_headers,
|
||||
)
|
||||
await phone.delete("/api/v1/session", headers=phone_headers)
|
||||
|
||||
store = main._passkey_store()
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=b"backup-credential",
|
||||
public_key=b"backup-public-key",
|
||||
sign_count=0,
|
||||
device_label="Backup key",
|
||||
management_id="backup-management-id",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as laptop:
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
listed = await laptop.get("/api/v1/passkeys")
|
||||
old_phone = next(
|
||||
item for item in listed.json()["passkeys"] if item["device_label"] == "Old phone"
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
}
|
||||
missing_grant = await laptop.delete(
|
||||
f"/api/v1/passkeys/{old_phone['management_id']}", headers=headers
|
||||
)
|
||||
grant = await fresh_grant(laptop, "revoke_passkey", old_phone["management_id"])
|
||||
removed = await laptop.delete(
|
||||
f"/api/v1/passkeys/{old_phone['management_id']}",
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
remaining = await laptop.get("/api/v1/passkeys")
|
||||
sign_in_options = await laptop.post("/api/v1/passkeys/authentication/options")
|
||||
|
||||
assert enrolled.status_code == 201
|
||||
assert listed.status_code == 200
|
||||
assert listed.headers["cache-control"] == "no-store"
|
||||
assert set(old_phone) == {
|
||||
"management_id", "device_label", "created_at", "active", "current"
|
||||
}
|
||||
assert old_phone["active"] is False
|
||||
assert old_phone["current"] is False
|
||||
assert b"old-phone-credential" not in listed.content
|
||||
assert b"old-phone-public-key" not in listed.content
|
||||
assert missing_grant.status_code == 428
|
||||
assert missing_grant.json()["detail"] == {
|
||||
"detail": "Fresh authorization required",
|
||||
"code": "step_up_required",
|
||||
"action": "revoke_passkey",
|
||||
"target": old_phone["management_id"],
|
||||
}
|
||||
assert removed.status_code == 200
|
||||
assert removed.json() == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert [item["device_label"] for item in remaining.json()["passkeys"]] == ["Backup key"]
|
||||
assert sign_in_options.status_code == 200
|
||||
assert sign_in_options.json()["allowCredentials"] == [
|
||||
{"id": "YmFja3VwLWNyZWRlbnRpYWw", "type": "public-key"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_removing_a_remote_passkey_also_revokes_its_active_session(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
phone_device = next(item for item in devices if item["device_label"] == "Phone")
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=phone_device["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(
|
||||
laptop, "revoke_passkey", phone_device["management_id"]
|
||||
)
|
||||
removed = await laptop.delete(
|
||||
f"/api/v1/passkeys/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
phone_status = await phone.get("/api/v1/session")
|
||||
laptop_status = await laptop.get("/api/v1/session")
|
||||
|
||||
assert removed.json() == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": True,
|
||||
}
|
||||
assert phone_status.status_code == 401
|
||||
assert phone_status.json()["code"] == "session_revoked"
|
||||
assert laptop_status.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_removing_the_current_passkey_keeps_the_current_session_active(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
current = next(
|
||||
item for item in (await client.get("/api/v1/sessions")).json()["devices"]
|
||||
if item["current"]
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"current-credential",
|
||||
public_key=b"current-public-key",
|
||||
sign_count=0,
|
||||
device_label="Laptop",
|
||||
management_id=current["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(client, "revoke_passkey", current["management_id"])
|
||||
removed = await client.delete(
|
||||
f"/api/v1/passkeys/{current['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
still_active = await client.get("/api/v1/session")
|
||||
activity = await client.get("/api/v1/security-events")
|
||||
|
||||
assert removed.json() == {
|
||||
"revoked": True,
|
||||
"current_session": True,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert still_active.status_code == 200
|
||||
assert activity.json()["events"][0]["kind"] == "passkey_revoked"
|
||||
assert activity.json()["events"][0]["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||
@@ -245,10 +737,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 +787,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,16 +1157,10 @@ 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 consume_step_up(self, grant, session_id, *, action, target):
|
||||
return True
|
||||
|
||||
def revoke_all(self):
|
||||
def revoke_all_access(self):
|
||||
raise SessionStoreError("database path and secret details")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
|
||||
monkeypatch.setattr(main, "_passkey_store", lambda: BrokenStore())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
@@ -641,6 +1177,49 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont
|
||||
assert "database path" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_devices_failure_preserves_passkeys_and_sessions(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
device = (await client.get("/api/v1/sessions")).json()["devices"][0]
|
||||
store = main._passkey_store()
|
||||
await asyncio.to_thread(
|
||||
store.register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=0,
|
||||
device_label="Phone",
|
||||
management_id=device["management_id"],
|
||||
)
|
||||
grant = await fresh_grant(client, "revoke_all_sessions", "all")
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TRIGGER block_session_delete BEFORE DELETE ON active_sessions "
|
||||
"BEGIN SELECT RAISE(ABORT, 'injected failure'); END"
|
||||
)
|
||||
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
"X-Step-Up-Grant": grant,
|
||||
},
|
||||
)
|
||||
session = await client.get("/api/v1/session")
|
||||
passkeys = await client.get("/api/v1/passkeys")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert session.status_code == 200
|
||||
assert session.json()["authenticated"] is True
|
||||
assert passkeys.status_code == 200
|
||||
assert [item["device_label"] for item in passkeys.json()["passkeys"]] == ["Phone"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
|
||||
calls = 0
|
||||
@@ -693,9 +1272,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 +1426,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 +1472,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 +1517,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 +1545,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())
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
@@ -272,6 +297,77 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["replacedAfterDeletion"] is True
|
||||
|
||||
|
||||
def test_enroll_passkey_bootstraps_with_fresh_authorization_and_web_authentication():
|
||||
script = f"""
|
||||
const createSessionBoundary=require({json.dumps(str(SESSION_JS))});
|
||||
const requests=[];
|
||||
const responses=[
|
||||
{{status:428,payload:{{detail:{{code:'step_up_required',action:'enroll_passkey',target:'current_device'}}}}}},
|
||||
{{status:201,payload:{{grant:'bootstrap-grant',expires_in:90}}}},
|
||||
{{status:201,payload:{{challenge:'AQID',rp:{{id:'forge.example',name:'Stackchain Dashboard'}},user:{{id:'BAUG',name:'stackchain-operator',displayName:'Stackchain operator'}},pubKeyCredParams:[],excludeCredentials:[],authenticatorSelection:{{userVerification:'required'}}}}}},
|
||||
{{status:201,payload:{{enrolled:true}}}},
|
||||
];
|
||||
const credential={{id:'new-passkey',type:'public-key',rawId:Uint8Array.from([1]).buffer,response:{{attestationObject:Uint8Array.from([2]).buffer,clientDataJSON:Uint8Array.from([3]).buffer,getTransports:()=>['internal']}}}};
|
||||
const boundary=createSessionBoundary({{
|
||||
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
|
||||
credentials:{{create:async options=>{{globalThis.creation=options.publicKey;return credential;}}}},
|
||||
promptAuthorization:async()=> 'recovery-token',
|
||||
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
|
||||
location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{const enrolled=await boundary.enrollPasskey();process.stdout.write(JSON.stringify({{enrolled,requests,creation:globalThis.creation}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["enrolled"] is True
|
||||
assert output["creation"]["challenge"] == {"0": 1, "1": 2, "2": 3}
|
||||
assert output["creation"]["user"]["id"] == {"0": 4, "1": 5, "2": 6}
|
||||
assert output["requests"][2]["grant"] == "bootstrap-grant"
|
||||
verification = json.loads(output["requests"][3]["body"])
|
||||
assert verification["challenge"] == "AQID"
|
||||
assert verification["credential"]["response"]["transports"] == ["internal"]
|
||||
|
||||
|
||||
def test_high_impact_fetch_uses_passkey_before_access_token_fallback():
|
||||
script = f"""
|
||||
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
|
||||
const requests=[];
|
||||
const responses=[
|
||||
{{status:428,payload:{{detail:{{code:'step_up_required',action:'close_issue',target:'stackchain/api#7'}}}}}},
|
||||
{{status:200,payload:{{challenge:'AQID',rpId:'forge.example',userVerification:'required',allowCredentials:[{{type:'public-key',id:'BAUG'}}]}}}},
|
||||
{{status:201,payload:{{grant:'passkey-grant',expires_in:90}}}},
|
||||
{{status:200,payload:{{closed:true}}}},
|
||||
];
|
||||
let prompted=false;
|
||||
const credential={{id:'credential',type:'public-key',rawId:Uint8Array.from([4,5,6]).buffer,response:{{authenticatorData:Uint8Array.from([7]).buffer,clientDataJSON:Uint8Array.from([8]).buffer,signature:Uint8Array.from([9]).buffer,userHandle:null}}}};
|
||||
const boundary=createSessionBoundary({{
|
||||
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
|
||||
credentials:{{get:async()=>credential}},
|
||||
promptAuthorization:async()=>{{prompted=true;return 'must-not-be-requested';}},
|
||||
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
|
||||
location:{{replace:()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{const response=await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/issues/7/close',{{method:'PATCH'}});process.stdout.write(JSON.stringify({{status:response.status,requests,prompted}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], text=True, capture_output=True, check=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["status"] == 200
|
||||
assert output["prompted"] is False
|
||||
assert output["requests"][1]["url"].endswith("/passkeys/authorization/options")
|
||||
assert json.loads(output["requests"][1]["body"]) == {
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
}
|
||||
assert output["requests"][2]["url"].endswith("/passkeys/authorization/verify")
|
||||
assert output["requests"][3]["grant"] == "passkey-grant"
|
||||
|
||||
|
||||
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -350,6 +446,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 +477,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(
|
||||
"""
|
||||
@@ -469,6 +596,65 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["confirmations"] == ["Sign out Pixel <script>?"]
|
||||
|
||||
|
||||
def test_enrolled_passkeys_can_be_loaded_and_selectively_removed():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responses = [
|
||||
{status:200,payload:{passkeys:[
|
||||
{management_id:'passkey-id-12345678', device_label:'Old phone <script>', created_at:1000, active:false, current:false},
|
||||
]}},
|
||||
{status:200,payload:{revoked:true,current_session:false,session_revoked:false}},
|
||||
];
|
||||
const passkeys = await boundary.listPasskeys();
|
||||
const outcome = await boundary.revokePasskey(passkeys[0]);
|
||||
state.passkeys = passkeys;
|
||||
state.outcome = outcome;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["passkeys"][0]["device_label"] == "Old phone <script>"
|
||||
assert result["outcome"] == {
|
||||
"revoked": True,
|
||||
"current_session": False,
|
||||
"session_revoked": False,
|
||||
}
|
||||
assert result["requests"][0]["url"] == "/dashboard/api/v1/passkeys"
|
||||
assert result["requests"][1]["url"] == (
|
||||
"/dashboard/api/v1/passkeys/passkey-id-12345678"
|
||||
)
|
||||
assert result["requests"][1]["method"] == "DELETE"
|
||||
assert result["requests"][1]["headers"]["x-csrf-token"] == "csrf-proof"
|
||||
assert result["confirmations"] == [
|
||||
"Remove the passkey for Old phone <script>? This cannot be undone."
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_pages_are_loaded_with_stable_cursors():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.responsePayload = {events:[{id:7,kind:'sign_in',method:'passkey',device_label:'Phone',target:'dashboard',created_at:1000}],next_cursor:7};
|
||||
const first = await boundary.listSecurityEvents();
|
||||
const second = await boundary.listSecurityEvents(first.next_cursor);
|
||||
state.first = first;
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["first"]["events"][0]["method"] == "passkey"
|
||||
assert [request["url"] for request in result["requests"]] == [
|
||||
"/dashboard/api/v1/security-events?limit=25",
|
||||
"/dashboard/api/v1/security-events?limit=25&cursor=7",
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
assert "Outcome confirmation pending" in source
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
@@ -490,5 +676,11 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
|
||||
assert '<button id="active-devices" type="button">Active devices</button>' in html
|
||||
assert 'id="active-devices-sheet"' in html
|
||||
assert 'aria-label="Active devices"' in html
|
||||
assert 'aria-label="Active devices and passkeys"' in html
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
@@ -121,6 +121,55 @@ 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_as_awaiting_authorization():
|
||||
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:'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 inbox=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
const item=inbox.list()[0];
|
||||
process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
item = output["item"]
|
||||
|
||||
assert item["label"] == "Awaiting authorization"
|
||||
assert item["status"] == "authorization"
|
||||
assert item["authorization_required"] is True
|
||||
assert item["outbox_kind"] == "issue-close"
|
||||
assert item["title"] == "stackchain/dashboard#27"
|
||||
assert item["route"] == {"kind": "issue", "repository": "stackchain/dashboard", "number": 27}
|
||||
assert item["quarantined"] is False
|
||||
assert output["partition"]["counts"] == {
|
||||
"waiting": 0, "sending": 0, "attention": 0, "authorization": 1
|
||||
}
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -183,6 +232,36 @@ process.stdout.write(JSON.stringify(drafts));
|
||||
assert by_id["mine"]["quarantined"] is False
|
||||
|
||||
|
||||
def test_draft_inbox_partitions_delivery_center_and_counts_only_safe_waiting_retries():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-comment.v1:stackchain/api#17', 'Unfinished comment'],
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
|
||||
{{id:'waiting',repository:'o/r',title:'Waiting issue',body:'Body',ownerLogin:'timmy',status:'queued',queuedAt:100,lastAttemptAt:90,lastAttemptError:'Network unavailable'}},
|
||||
{{id:'sending',repository:'o/r',title:'Sending issue',body:'Body',ownerLogin:'timmy',status:'sending',queuedAt:200}},
|
||||
{{id:'attention',repository:'o/r',title:'Broken issue',body:'Body',ownerLogin:'timmy',status:'attention',queuedAt:300}},
|
||||
{{id:'other-user',repository:'o/r',title:'Private issue',body:'Body',ownerLogin:'alexander',status:'queued',queuedAt:400}}
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'uncertain',kind:'issue-comment',repository:'o/r',number:4,body:'Maybe sent',ownerLogin:'timmy',status:'attention',deliveryState:'uncertain',queuedAt:500}}
|
||||
]}})],
|
||||
]);
|
||||
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 inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}});
|
||||
process.stdout.write(JSON.stringify(inbox.partition()));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert len(output["drafts"]) == 1
|
||||
assert len(output["deliveries"]) == 5
|
||||
assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0}
|
||||
assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"]
|
||||
waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting")
|
||||
assert waiting["last_attempt_at"] == 90
|
||||
assert waiting["last_attempt_error"] == "Network unavailable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
html = await dashboard()
|
||||
@@ -197,10 +276,42 @@ 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 "item.kind === 'authored-outbox' && closureOutbox" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_waiting_work():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="delivery-center"' in html
|
||||
assert 'id="retry-waiting-deliveries"' in html
|
||||
assert 'Waiting <strong data-delivery-count="waiting">' in html
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="draft-authorize"' in html
|
||||
assert '>Authorize & close</button>' in html
|
||||
assert "list.querySelectorAll('.draft-authorize')" in html
|
||||
assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "item.status === 'authorization' ? 'Awaiting authorization'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_only_flushes_account_bound_outboxes_after_a_fresh_identity_snapshot():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -430,6 +430,62 @@ async def test_repository_page_reports_more_results_and_targeted_access_uses_rep
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_search_finds_visible_repositories_without_loading_pages():
|
||||
requests = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append((request.url.path, dict(request.url.params)))
|
||||
return httpx.Response(200, json={
|
||||
"ok": True,
|
||||
"data": [
|
||||
{"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True},
|
||||
"invalid",
|
||||
],
|
||||
})
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.search_repositories(" mobile ", limit=20)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == [{
|
||||
"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True,
|
||||
}]
|
||||
assert requests == [(
|
||||
"/api/v1/repos/search", {"q": "mobile", "limit": "20", "page": "1"},
|
||||
)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_search_endpoint_returns_minimal_visible_matches(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def search(query, limit):
|
||||
calls.append((query, limit))
|
||||
return [{
|
||||
"id": 151,
|
||||
"name": "mobile",
|
||||
"full_name": "stackchain/mobile",
|
||||
"html_url": "https://forge.example/stackchain/mobile",
|
||||
"description": "secret context that the picker does not need",
|
||||
"private": True,
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "search_repositories", search)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/repositories/search?q=%20mobile%20&limit=20")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {"items": [{
|
||||
"id": 151, "name": "mobile", "full_name": "stackchain/mobile",
|
||||
}]}
|
||||
assert calls == [("mobile", 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
access_calls = []
|
||||
@@ -1141,6 +1197,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 +1337,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 +1650,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 +1706,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 +1720,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 +1756,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 = []
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
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_binary_upload():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls = [];
|
||||
const file = new Blob(['png-bytes'],{{type:'image/png'}}); file.name='checkout.png';
|
||||
const controller = attachment.create({{
|
||||
createOperationId: () => 'attachment-comment-471',
|
||||
readDataUrl: async () => {{ throw new Error('base64 conversion must not run'); }},
|
||||
upload: async payload => {{ calls.push({{
|
||||
repository:payload.repository,number:payload.number,filename:payload.filename,
|
||||
content_type:payload.content_type,isBlob:payload.blob instanceof Blob,
|
||||
text:await payload.blob.text(),operation_id:payload.operation_id
|
||||
}}); return {{markdown:''}}; }},
|
||||
}});
|
||||
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"
|
||||
)
|
||||
assert output["second"] == output["first"]
|
||||
assert output["calls"] == [{
|
||||
"repository": "stackchain/api",
|
||||
"number": 17,
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"isBlob": True,
|
||||
"text": "png-bytes",
|
||||
"operation_id": "attachment-comment-471",
|
||||
}]
|
||||
assert output["state"] == {
|
||||
"name": "checkout.png", "size": 9, "uploaded": True
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_attachment_retry_reuses_operation_key_until_file_changes():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const keys=[];
|
||||
let sequence=0;
|
||||
let fail=true;
|
||||
const controller=attachment.create({{
|
||||
createOperationId:()=> 'attachment-' + (++sequence),
|
||||
readDataUrl:async file=>'data:'+file.type+';base64,iVBORw0KGgo=',
|
||||
upload:async payload=>{{keys.push(payload.operation_id);if(fail){{fail=false;throw new Error('offline');}}return {{markdown:''}};}},
|
||||
}});
|
||||
controller.select({{name:'first.png',type:'image/png',size:8}});
|
||||
(async()=>{{
|
||||
try{{await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');}}catch(_error){{}}
|
||||
await controller.prepareComment({{repository:'stackchain/api',number:17}},'Evidence');
|
||||
controller.select({{name:'replacement.png',type:'image/png',size:8}});
|
||||
await controller.prepareComment({{repository:'stackchain/api',number:17}},'Replacement');
|
||||
process.stdout.write(JSON.stringify(keys));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == [
|
||||
"attachment-1", "attachment-1", "attachment-2"
|
||||
]
|
||||
|
||||
|
||||
def test_selected_screenshot_serializes_as_binary_blob_without_base64_expansion():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const calls=[];
|
||||
const file=new Blob(['binary-image'],{{type:'image/webp'}});
|
||||
file.name='phone.webp';
|
||||
const controller=attachment.create({{
|
||||
readDataUrl:async()=>{{throw new Error('base64 conversion must not run');}},
|
||||
upload:async()=>{{calls.push('upload');}},
|
||||
}});
|
||||
controller.select(file);
|
||||
controller.serialize().then(async value=>process.stdout.write(JSON.stringify({{
|
||||
filename:value.filename,contentType:value.contentType,
|
||||
isBlob:value.blob instanceof Blob,size:value.blob.size,text:await value.blob.text(),calls
|
||||
}})));
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"isBlob": True,
|
||||
"size": 12,
|
||||
"text": "binary-image",
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_oversized_screenshot_is_optimized_before_preview_and_durable_serialization():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const original = new Blob([new Uint8Array(attachment.MAX_BYTES + 200)], {{type:'image/png'}});
|
||||
original.name = 'phone.png';
|
||||
const calls = [];
|
||||
const controller = attachment.create({{
|
||||
optimizeImage: async file => {{
|
||||
calls.push({{name:file.name,type:file.type,size:file.size}});
|
||||
const optimized = new Blob(['optimized-png'], {{type:'image/png'}});
|
||||
optimized.name = file.name;
|
||||
return optimized;
|
||||
}},
|
||||
upload: async () => {{ throw new Error('must not upload during selection'); }},
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.select(original);
|
||||
const serialized = await controller.serialize();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,state:controller.state(),name:serialized.filename,
|
||||
contentType:serialized.contentType,size:serialized.blob.size,
|
||||
text:await serialized.blob.text(),sameBlob:serialized.blob===original,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"calls": [{
|
||||
"name": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 2 * 1024 * 1024 + 200,
|
||||
}],
|
||||
"state": {"name": "phone.png", "size": 13, "uploaded": False},
|
||||
"name": "phone.png",
|
||||
"contentType": "image/png",
|
||||
"size": 13,
|
||||
"text": "optimized-png",
|
||||
"sameBlob": False,
|
||||
}
|
||||
|
||||
|
||||
def test_browser_optimizer_downscales_until_encoded_image_fits_limit():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
const original = new Blob([new Uint8Array(attachment.MAX_BYTES + 1)], {{type:'image/jpeg'}});
|
||||
original.name = 'camera.jpg';
|
||||
const attempts = [];
|
||||
let closed = false;
|
||||
const canvas = {{
|
||||
width: 0, height: 0,
|
||||
getContext: () => ({{drawImage: () => {{}}}}),
|
||||
toBlob: callback => {{
|
||||
attempts.push([canvas.width, canvas.height]);
|
||||
const size = attempts.length === 1 ? attachment.MAX_BYTES + 50 : attachment.MAX_BYTES - 50;
|
||||
callback(new Blob([new Uint8Array(size)], {{type:'image/jpeg'}}));
|
||||
}},
|
||||
}};
|
||||
(async()=>{{
|
||||
const result = await attachment.optimizeImage(original, {{
|
||||
createImageBitmap: async () => ({{width:2000,height:1000,close:()=>{{closed=true;}}}}),
|
||||
createCanvas: () => canvas,
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
attempts,closed,size:result.size,type:result.type,name:result.name,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert len(output["attempts"]) == 2
|
||||
assert output["attempts"][1][0] < output["attempts"][0][0]
|
||||
assert output["attempts"][1][1] < output["attempts"][0][1]
|
||||
assert output["closed"] is True
|
||||
assert output["size"] == 2 * 1024 * 1024 - 50
|
||||
assert output["type"] == "image/jpeg"
|
||||
assert output["name"] == "camera.jpg"
|
||||
|
||||
|
||||
def test_binary_screenshot_builds_multipart_body_with_original_bytes():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
const blob=new Blob(['original-bytes'],{{type:'image/png'}});
|
||||
const form=attachment.multipart({{filename:'phone.png',contentType:'image/png',blob}});
|
||||
const file=form.get('file');
|
||||
(async()=>process.stdout.write(JSON.stringify({{
|
||||
filename:file.name,type:file.type,size:file.size,text:await file.text()
|
||||
}})))();
|
||||
"""
|
||||
|
||||
assert json.loads(run_node(script)) == {
|
||||
"filename": "phone.png",
|
||||
"type": "image/png",
|
||||
"size": 14,
|
||||
"text": "original-bytes",
|
||||
}
|
||||
|
||||
|
||||
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_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
||||
script = f"""
|
||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
||||
const controller=attachment.create({{readDataUrl:async()=>'',upload:async()=>{{}}}});
|
||||
try {{
|
||||
controller.restore({{filename:'saved.png',contentType:'image/png',stored:true}});
|
||||
process.stdout.write(JSON.stringify({{restored:true,state:controller.state()}}));
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{restored:false,message:error.message,state:controller.state()}}));
|
||||
}}
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output["restored"] is False
|
||||
assert "saved screenshot" in output["message"].lower()
|
||||
assert output["state"] 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(hydrated.attachment);" in source
|
||||
assert "createIssueAttachmentController.clear();" in source
|
||||
assert ".create-issue-attachment" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
|
||||
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
|
||||
source = DASHBOARD.read_text()
|
||||
handler = re.search(
|
||||
r"list\.querySelectorAll\('\.draft-edit'\).*?addEventListener\('click', async \(\) => \{(?P<body>.*?)\n \}\);",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert handler is not None
|
||||
body = handler.group("body")
|
||||
assert "await issueOutbox.hydrateForEdit(queued.id)" in body
|
||||
assert body.index("await issueOutbox.hydrateForEdit(queued.id)") < body.index(
|
||||
"openCreateIssueSheet()"
|
||||
)
|
||||
assert "catch (error)" in body
|
||||
assert "saved screenshot" in body.lower()
|
||||
|
||||
|
||||
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_oversized_attachment_view_shows_progress_then_previews_optimized_blob():
|
||||
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='';this.disabled=false; }}
|
||||
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 original=new Blob([new Uint8Array(attachment.MAX_BYTES+10)],{{type:'image/png'}}); original.name='large.png';
|
||||
const optimized=new Blob(['small'],{{type:'image/png'}}); optimized.name='large.png';
|
||||
let finish;
|
||||
const gate=new Promise(resolve=>{{finish=resolve;}});
|
||||
const previewed=[];
|
||||
attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async()=>{{await gate;return optimized;}},
|
||||
createObjectURL:blob=>{{previewed.push(blob===optimized);return 'blob:optimized';}},
|
||||
revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[original];
|
||||
const pending=input.dispatch('change');
|
||||
const during={{message:status.textContent,disabled:input.disabled,hidden:preview.hidden}};
|
||||
finish();
|
||||
Promise.resolve(pending).then(()=>process.stdout.write(JSON.stringify({{
|
||||
during,after:{{message:status.textContent,disabled:input.disabled,hidden:preview.hidden,
|
||||
src:image.src,meta:meta.textContent}},previewed
|
||||
}}))).catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output["during"] == {
|
||||
"message": "Optimizing screenshot…", "disabled": True, "hidden": True
|
||||
}
|
||||
assert output["after"]["message"] == "Screenshot optimized and ready to upload."
|
||||
assert output["after"]["disabled"] is False
|
||||
assert output["after"]["hidden"] is False
|
||||
assert output["after"]["src"] == "blob:optimized"
|
||||
assert "large.png" in output["after"]["meta"]
|
||||
assert output["previewed"] == [True]
|
||||
|
||||
|
||||
def test_removing_screenshot_during_optimization_cancels_stale_selection():
|
||||
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='';this.disabled=false; }}
|
||||
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 original=new Blob([new Uint8Array(attachment.MAX_BYTES+10)],{{type:'image/webp'}}); original.name='large.webp';
|
||||
const optimized=new Blob(['small'],{{type:'image/webp'}}); optimized.name='large.webp';
|
||||
let finish;
|
||||
const gate=new Promise(resolve=>{{finish=resolve;}});
|
||||
const controller=attachment.mount({{
|
||||
input,preview,image,meta,remove,status,
|
||||
optimizeImage:async()=>{{await gate;return optimized;}},
|
||||
createObjectURL:()=> 'blob:stale',revokeObjectURL:()=>{{}},upload:async()=>{{}},
|
||||
}});
|
||||
input.files=[original];
|
||||
const pending=input.dispatch('change');
|
||||
remove.dispatch('click');
|
||||
finish();
|
||||
Promise.resolve(pending).then(async()=>process.stdout.write(JSON.stringify({{
|
||||
state:controller.state(),serialized:await controller.serialize(),disabled:input.disabled,
|
||||
hidden:preview.hidden,src:image.src,status:status.textContent,
|
||||
}}))).catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
|
||||
assert output == {
|
||||
"state": None,
|
||||
"serialized": None,
|
||||
"disabled": False,
|
||||
"hidden": True,
|
||||
"src": "",
|
||||
"status": "Screenshot removed. Your comment is unchanged.",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_comment_actions_upload_binary_multipart_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
|
||||
assert "'Idempotency-Key': payload.operation_id" in source
|
||||
assert "body: issueAttachment.multipart(payload)" in source
|
||||
|
||||
|
||||
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 "automatically optimizes oversized screenshots" 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
|
||||
|
||||
|
||||
def test_issue_screenshot_comments_queue_serialized_bytes_before_clearing_or_advancing():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "async function queueIssueScreenshotComment" in source
|
||||
assert "attachment: await issueAttachmentController.serialize()" in source
|
||||
assert "await authoredOutbox.enqueueDurably(message)" in source
|
||||
assert "await issueCommentNext.admit(item, message)" in source
|
||||
assert "navigator.onLine === false" in source
|
||||
@@ -0,0 +1,404 @@
|
||||
import asyncio
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import gitea_proxy, main
|
||||
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"mobile screenshot"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_attachment_idempotency():
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
yield
|
||||
main._authored_action_operations.clear()
|
||||
main._idempotency_ledger.clear()
|
||||
|
||||
|
||||
@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": "",
|
||||
}
|
||||
assert calls == [
|
||||
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_accepts_binary_multipart_without_base64_expansion(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
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",
|
||||
files={"file": ("checkout.png", PNG_BYTES, "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert calls == [
|
||||
("stackchain/api", 17, "checkout.png", "image/png", PNG_BYTES)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_replays_confirmed_upload_for_same_key(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
calls.append((repository, number, filename, content_type, content))
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
payload = {
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": "attachment-retry-471"},
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": "attachment-retry-471"},
|
||||
)
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_coalesces_concurrent_upload_retries(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def upload(_repository, _number, filename, _content_type, content):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.05)
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_assigned_issue_attachment", upload)
|
||||
payload = {
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES).decode("ascii"),
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first, retry = await asyncio.gather(*[
|
||||
client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json=payload,
|
||||
headers={"Idempotency-Key": "attachment-concurrent-471"},
|
||||
)
|
||||
for _ in range(2)
|
||||
])
|
||||
|
||||
assert first.status_code == retry.status_code == 201
|
||||
assert first.json() == retry.json()
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_attachment_endpoint_rejects_changed_upload_for_used_key(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def upload(_repository, _number, filename, _content_type, content):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {
|
||||
"name": filename,
|
||||
"url": "https://forge.example/attachments/checkout.png",
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
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:
|
||||
accepted = 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"),
|
||||
},
|
||||
headers={"Idempotency-Key": "attachment-conflict-471"},
|
||||
)
|
||||
conflict = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/17/attachments",
|
||||
json={
|
||||
"filename": "checkout.png",
|
||||
"content_type": "image/png",
|
||||
"data": base64.b64encode(PNG_BYTES + b" changed").decode("ascii"),
|
||||
},
|
||||
headers={"Idempotency-Key": "attachment-conflict-471"},
|
||||
)
|
||||
|
||||
assert accepted.status_code == 201
|
||||
assert conflict.status_code == 409
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@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"] == (
|
||||
".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
|
||||
@@ -71,6 +71,54 @@ 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_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
values.set('stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||
id:'capture-1',operationId:'capture-1',repository:'o/r',title:'Before',body:'',
|
||||
ownerLogin:'timmy',status:'queued',attachment:{{filename:'phone.png',contentType:'image/png',stored:true}},
|
||||
}}]}}));
|
||||
const gets=[];
|
||||
const outbox=createIssueOutbox({{storage,backgroundSync:{{
|
||||
get:async id=>{{gets.push(id);return {{id,operationId:'capture-1',attachment:{{filename:'phone.png',contentType:'image/png',data:'durable-image-bytes'}}}};}},
|
||||
}}}});
|
||||
(async()=>{{
|
||||
const hydrated=await outbox.hydrateForEdit('capture-1');
|
||||
process.stdout.write(JSON.stringify({{hydrated,gets,stored:values.get('stackchain.issue-outbox.v1')}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["hydrated"]["attachment"]["data"] == "durable-image-bytes"
|
||||
assert output["gets"] == ["capture-1"]
|
||||
assert "durable-image-bytes" not in output["stored"]
|
||||
|
||||
|
||||
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 +202,81 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{resul
|
||||
assert output["remaining"][0]["status"] == "queued"
|
||||
|
||||
|
||||
def test_issue_outbox_exposes_sending_then_preserves_transient_attempt_details():
|
||||
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 release; const gate = new Promise(resolve => release = resolve);
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=>'timmy', createOperationId:()=>'attempt', now:()=>888,
|
||||
fetchJson:async()=>{{await gate;const error=new Error('Gateway timeout');error.status=503;throw error;}},
|
||||
}});
|
||||
outbox.enqueue({{repository:'o/r',title:'Wait',body:'Context'}});
|
||||
const pending=outbox.flush('timmy');
|
||||
const sending=outbox.list()[0];
|
||||
release();
|
||||
pending.then(()=>process.stdout.write(JSON.stringify({{sending,after:outbox.list()[0]}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["sending"]["status"] == "sending"
|
||||
assert output["after"]["status"] == "queued"
|
||||
assert output["after"]["lastAttemptAt"] == 888
|
||||
assert output["after"]["lastAttemptError"] == "Gateway timeout"
|
||||
|
||||
|
||||
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={{}})=>{{
|
||||
const form=options.body instanceof FormData ? options.body : null;
|
||||
const file=form?.get('file');
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],multipart:Boolean(form),
|
||||
filename:file?.name,text:file?await file.text():null}});
|
||||
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:''}};
|
||||
return{{id:8}};
|
||||
}},
|
||||
}});
|
||||
const queued=outbox.enqueue({{repository:'o/r',title:'Visual bug',attachment:{{filename:'screen.png',contentType:'image/png',data:'b2ZmbGluZQ=='}}}});
|
||||
(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
|
||||
uploads = [call for call in output["calls"] if call["url"].endswith("/attachments")]
|
||||
assert uploads[-1]["multipart"] is True
|
||||
assert uploads[-1]["filename"] == "screen.png"
|
||||
assert uploads[-1]["text"] == "offline"
|
||||
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:''}}]}}));
|
||||
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))});
|
||||
@@ -336,6 +459,126 @@ Promise.resolve().then(async () => {{
|
||||
assert output["items"][0]["title"] == "Keep this"
|
||||
|
||||
|
||||
def test_durable_screenshot_is_mirrored_as_blob_before_a_payload_free_local_index_is_committed():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const events = []; const snapshots = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{
|
||||
if (value.includes('binary-screenshot-bytes')) throw new Error('screenshot leaked into localStorage');
|
||||
events.push('local-index'); values.set(key,value);
|
||||
}},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'quota-safe-1',
|
||||
backgroundSync: {{
|
||||
reconcile: async items => {{ events.push('indexeddb'); snapshots.push(items); }},
|
||||
requestSync: async () => {{ events.push('sync'); }},
|
||||
}},
|
||||
}});
|
||||
(async () => {{
|
||||
const blob=new Blob(['binary-screenshot-bytes'],{{type:'image/webp'}});
|
||||
const result = await outbox.enqueueDurably({{
|
||||
repository:'stackchain/dashboard', title:'Mobile layout',
|
||||
attachment:{{filename:'phone.webp',contentType:'image/webp',blob}},
|
||||
}});
|
||||
const durable=snapshots[0][0].attachment;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
events, mirrored:{{filename:durable.filename,contentType:durable.contentType,
|
||||
isBlob:durable.blob instanceof Blob,size:durable.blob?.size,text:await durable.blob?.text()}},
|
||||
local:outbox.list()[0], result:result.item, raw:values.get('stackchain.issue-outbox.v1'),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
||||
assert output["mirrored"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"isBlob": True,
|
||||
"size": 23,
|
||||
"text": "binary-screenshot-bytes",
|
||||
}
|
||||
assert output["local"]["attachment"] == {
|
||||
"filename": "phone.webp",
|
||||
"contentType": "image/webp",
|
||||
"stored": True,
|
||||
}
|
||||
assert "binary-screenshot-bytes" not in output["raw"]
|
||||
assert output["result"]["attachment"] == output["local"]["attachment"]
|
||||
|
||||
|
||||
def test_replacing_a_durable_screenshot_updates_indexeddb_before_the_local_reference():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const events = []; const snapshots = [];
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{
|
||||
if (value.includes('new-image-bytes')) throw new Error('replacement leaked into localStorage');
|
||||
events.push('local-index'); values.set(key,value);
|
||||
}},
|
||||
}};
|
||||
const backgroundSync = {{
|
||||
reconcile:async items => {{ events.push('indexeddb'); snapshots.push(items); }},
|
||||
requestSync:async () => {{ events.push('sync'); }},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:()=> 'timmy',
|
||||
createOperationId:(() => {{ let sequence=0; return () => 'replace-' + (++sequence); }})(),
|
||||
}});
|
||||
(async () => {{
|
||||
const admitted = await outbox.enqueueDurably({{repository:'o/r',title:'Visual'}});
|
||||
events.length = 0;
|
||||
const result = await outbox.updateDurably(admitted.item.id, {{
|
||||
...admitted.item,
|
||||
attachment:{{filename:'new.png',contentType:'image/png',data:'new-image-bytes'}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
events, mirrored:snapshots.at(-1)[0], local:outbox.list()[0], result:result.item,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["events"] == ["indexeddb", "local-index", "sync"]
|
||||
assert output["mirrored"]["attachment"]["data"] == "new-image-bytes"
|
||||
assert output["local"]["attachment"]["stored"] is True
|
||||
assert "data" not in output["local"]["attachment"]
|
||||
assert output["result"] == output["local"]
|
||||
|
||||
|
||||
def test_next_durable_admission_migrates_legacy_screenshot_payloads_out_of_localstorage():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map([['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
||||
id:'legacy',operationId:'legacy',repository:'o/r',title:'Queued before upgrade',body:'',
|
||||
labelIds:[],ownerLogin:'timmy',status:'queued',queuedAt:1,
|
||||
attachment:{{filename:'old.png',contentType:'image/png',data:'legacy-image-bytes'}},
|
||||
}}]}})]]);
|
||||
const mirrored = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'new-item',
|
||||
backgroundSync:{{reconcile:async items=>mirrored.push(items),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await outbox.enqueueDurably({{repository:'o/r',title:'New queue item'}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
mirrored:mirrored[0],local:outbox.list(),raw:values.get('stackchain.issue-outbox.v1'),
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mirrored"][0]["attachment"]["data"] == "legacy-image-bytes"
|
||||
assert output["local"][0]["attachment"]["stored"] is True
|
||||
assert "data" not in output["local"][0]["attachment"]
|
||||
assert "legacy-image-bytes" not in output["raw"]
|
||||
|
||||
|
||||
def test_issue_outbox_reports_degraded_admission_without_losing_foreground_item():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
@@ -434,6 +677,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:''}}]);
|
||||
process.stdout.write(JSON.stringify(outbox.list()[0]));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["deliveredIssue"]["number"] == 7
|
||||
assert output["attachmentMarkdown"] == ""
|
||||
|
||||
|
||||
def test_page_preserves_background_create_and_start_until_the_matching_account_continues_it():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
||||
@@ -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-v84" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
+151
-19
@@ -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}
|
||||
@@ -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}]
|
||||
@@ -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))});
|
||||
@@ -171,6 +193,67 @@ const controller = createLoginController({{
|
||||
)
|
||||
|
||||
|
||||
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const requests = [];
|
||||
const state = {{ replaced: null }};
|
||||
const credential = {{
|
||||
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1,2,3]).buffer,
|
||||
response:{{
|
||||
authenticatorData:Uint8Array.from([4]).buffer,
|
||||
clientDataJSON:Uint8Array.from([5]).buffer,
|
||||
signature:Uint8Array.from([6]).buffer,
|
||||
userHandle:null,
|
||||
}},
|
||||
}};
|
||||
const controller = createLoginController({{
|
||||
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
|
||||
passkeyButton:{{disabled:false}},
|
||||
credentials:{{get:async options=>{{ state.publicKey=options.publicKey; return credential; }}}},
|
||||
fetchImpl:async (url, options={{}})=>{{
|
||||
requests.push({{url, body:options.body ? JSON.parse(options.body) : null}});
|
||||
if (url.endsWith('/options')) return new Response(JSON.stringify({{
|
||||
challenge:'AQID', rpId:'forge.example', userVerification:'required',
|
||||
allowCredentials:[{{type:'public-key',id:'BAUG'}}],
|
||||
}}),{{status:200,headers:{{'Content-Type':'application/json'}}}});
|
||||
return new Response('{{}}',{{status:200}});
|
||||
}},
|
||||
location:{{replace:value=>state.replaced=value}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await controller.signInWithPasskey('Timmy’s Pixel');
|
||||
process.stdout.write(JSON.stringify({{requests,state}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["state"]["publicKey"]["challenge"] == {"0": 1, "1": 2, "2": 3}
|
||||
assert output["state"]["replaced"] == "./"
|
||||
assert output["requests"][1]["url"] == "api/v1/passkeys/authentication/verify"
|
||||
assert output["requests"][1]["body"] == {
|
||||
"challenge": "AQID",
|
||||
"credential": {
|
||||
"id": "credential-id",
|
||||
"type": "public-key",
|
||||
"rawId": "AQID",
|
||||
"response": {
|
||||
"authenticatorData": "BA",
|
||||
"clientDataJSON": "BQ",
|
||||
"signature": "Bg",
|
||||
"userHandle": None,
|
||||
},
|
||||
},
|
||||
"device_label": "Timmy’s Pixel",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
}
|
||||
assert "access_token" not in json.dumps(output)
|
||||
|
||||
|
||||
def test_login_sends_a_bounded_device_label_with_the_access_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
|
||||
@@ -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-v84" in worker
|
||||
@@ -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
|
||||
@@ -9,7 +9,7 @@ CSS = ROOT / "frontend" / "dashboard.css"
|
||||
WORKER = ROOT / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
@@ -23,9 +23,15 @@ def test_all_conversation_composers_are_wired_to_keyboard_safe_panels():
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
assert "panel:qs('.create-issue-panel')" in dashboard
|
||||
assert "workspace:qs('#create-issue-form')" in dashboard
|
||||
assert "focusWithin:true" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
assert ".create-issue-panel.composer-keyboard-active" in css
|
||||
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
|
||||
|
||||
|
||||
def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
@@ -35,4 +41,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-v84" 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
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-composer-viewport.js"
|
||||
|
||||
|
||||
def run_controller_scenario(scenario: str) -> dict:
|
||||
def run_controller_scenario(scenario: str, *, focus_within: bool = False) -> dict:
|
||||
harness = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
class Target {{
|
||||
@@ -41,7 +41,7 @@ const status = element(); status.workspace = workspace;
|
||||
const document = {{activeElement:null}};
|
||||
const scheduled = [];
|
||||
const controller = createController({{
|
||||
entries:[{{panel, workspace, composer, submit, status}}], viewport, mediaQuery, document,
|
||||
entries:[{{panel, workspace, composer, submit, status, focusWithin:{json.dumps(focus_within)}}}], viewport, mediaQuery, document,
|
||||
schedule: callback => scheduled.push(callback),
|
||||
}});
|
||||
function flush() {{ while (scheduled.length) scheduled.shift()(); }}
|
||||
@@ -127,3 +127,39 @@ process.stdout.write(JSON.stringify({geometry:panel.style.values, resize:viewpor
|
||||
)
|
||||
|
||||
assert result == {"geometry": {}, "resize": 0, "reveals": 0}
|
||||
|
||||
|
||||
def test_focus_within_reveals_each_active_capture_field_without_duplicate_viewport_listeners():
|
||||
result = run_controller_scenario(
|
||||
"""
|
||||
controller.start();
|
||||
panel.scrollTop = 180;
|
||||
document.activeElement = composer;
|
||||
workspace.dispatch('focusin', {target:composer});
|
||||
document.activeElement = submit;
|
||||
workspace.dispatch('focusin', {target:submit});
|
||||
viewport.dispatch('resize');
|
||||
flush();
|
||||
process.stdout.write(JSON.stringify({
|
||||
composerReveals:composer.reveals.length,
|
||||
submitReveals:submit.reveals.length,
|
||||
workspaceReveals:workspace.reveals.length,
|
||||
resizeListeners:viewport.count('resize'),
|
||||
scrollListeners:viewport.count('scroll'),
|
||||
geometry:panel.style.values,
|
||||
}));
|
||||
""",
|
||||
focus_within=True,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"composerReveals": 1,
|
||||
"submitReveals": 2,
|
||||
"workspaceReveals": 0,
|
||||
"resizeListeners": 1,
|
||||
"scrollListeners": 1,
|
||||
"geometry": {
|
||||
"--composer-viewport-top": "12px",
|
||||
"--composer-viewport-height": "360px",
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+496
-16
@@ -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
|
||||
@@ -2044,6 +2397,23 @@ async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_f
|
||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_requires_explicit_searchable_repository_selection():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-repository-search" type="search" maxlength="80"' in html
|
||||
assert 'placeholder="Search accessible repositories"' in html
|
||||
assert 'id="create-issue-repository-results" role="listbox"' in html
|
||||
assert '<option value="">Choose repository</option>' in html
|
||||
assert "issueCapture.searchRepositories(query)" in html
|
||||
assert "if (query.length < 2) {\n issueCapture.searchRepositories(query);" in html
|
||||
assert "renderIssueRepositoryResults(state.items)" in html
|
||||
assert "selectIssueCaptureRepository(repository)" in html
|
||||
assert "const hasRepository = Boolean(qs('#create-issue-repository').value);" in html
|
||||
assert '.create-issue-repository-result { min-height:44px;' in html
|
||||
assert '.create-issue-repository-picker { min-width:0;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -2192,6 +2562,53 @@ capture.loadRepositoryPage(2).catch(() => {{
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_repository_search_ignores_late_results_and_preserves_draft():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const pending = {{}};
|
||||
const calls = [];
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
fetchJson: url => {{
|
||||
calls.push(url);
|
||||
return new Promise(resolve => {{ pending[url] = resolve; }});
|
||||
}},
|
||||
}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||
const first = capture.searchRepositories('mob');
|
||||
const second = capture.searchRepositories('mobile');
|
||||
pending['api/v1/repositories/search?q=mobile&limit=20']({{items:[{{full_name:'stackchain/mobile'}}]}});
|
||||
second.then(secondResult => {{
|
||||
pending['api/v1/repositories/search?q=mob&limit=20']({{items:[{{full_name:'stackchain/obsolete'}}]}});
|
||||
first.then(firstResult => process.stdout.write(JSON.stringify({{
|
||||
calls, firstResult, secondResult, draft:capture.loadDraft()
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"calls": [
|
||||
"api/v1/repositories/search?q=mob&limit=20",
|
||||
"api/v1/repositories/search?q=mobile&limit=20",
|
||||
],
|
||||
"firstResult": {"status": "stale", "items": []},
|
||||
"secondResult": {"status": "ready", "items": [{"full_name": "stackchain/mobile"}]},
|
||||
"draft": {
|
||||
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
@@ -3948,12 +4365,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 <token></span>' in output["html"]
|
||||
assert '<span class="review-line-code">+new & safe</span>' in output["html"]
|
||||
assert 'aria-label="Comment on src/<api>.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 +4421,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 +4655,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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-v84" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -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"
|
||||
)
|
||||
== 2 * 1024 * 1024 + 64 * 1024
|
||||
)
|
||||
assert main.request_body_limit("POST", "/unrelated/attachments") is None
|
||||
assert main.request_body_limit("POST", "/unrelated") is None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def security_access(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "attempts.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_security_activity_lists_private_sign_in_history(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as anonymous:
|
||||
rejected = await anonymous.get("/api/v1/security-events")
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
signed_in = await client.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Timmy's phone",
|
||||
},
|
||||
)
|
||||
activity = await client.get("/api/v1/security-events", params={"limit": 25})
|
||||
|
||||
assert rejected.status_code == 401
|
||||
assert signed_in.status_code == 200
|
||||
assert activity.status_code == 200
|
||||
assert activity.headers["cache-control"] == "no-store"
|
||||
assert activity.json() == {
|
||||
"events": [
|
||||
{
|
||||
"id": 1,
|
||||
"kind": "sign_in",
|
||||
"method": "token",
|
||||
"device_label": "Timmy's phone",
|
||||
"target": "dashboard",
|
||||
"created_at": activity.json()["events"][0]["created_at"],
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
"next_cursor": None,
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"correct horse battery staple" not in persisted
|
||||
assert b"stackchain_session" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_activity_limit_is_validated(security_access):
|
||||
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", "device_label": "Phone"},
|
||||
)
|
||||
too_large = await client.get("/api/v1/security-events", params={"limit": 101})
|
||||
invalid_cursor = await client.get("/api/v1/security-events", params={"cursor": 0})
|
||||
|
||||
assert too_large.status_code == 422
|
||||
assert invalid_cursor.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_revoked_device_activity_survives_live_session_removal(security_access):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
phone_device = next(device for device in devices if device["device_label"] == "Phone")
|
||||
csrf = laptop.cookies["stackchain_csrf"]
|
||||
grant = await laptop.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_device",
|
||||
"target": phone_device["management_id"],
|
||||
},
|
||||
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
|
||||
)
|
||||
revoked = await laptop.delete(
|
||||
f"/api/v1/sessions/{phone_device['management_id']}",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": csrf,
|
||||
"X-Step-Up-Grant": grant.json()["grant"],
|
||||
},
|
||||
)
|
||||
active = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
history = (await laptop.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert revoked.status_code == 200
|
||||
assert [device["device_label"] for device in active] == ["Laptop"]
|
||||
assert [(event["kind"], event["device_label"]) for event in history] == [
|
||||
("device_revoked", "Phone"),
|
||||
("sign_in", "Laptop"),
|
||||
("sign_in", "Phone"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remote_revocation_reservation_failure_preserves_the_device(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
|
||||
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
|
||||
):
|
||||
await phone.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
await laptop.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
|
||||
)
|
||||
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
target = next(device for device in devices if device["device_label"] == "Phone")
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await laptop.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_device",
|
||||
"target": target["management_id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await laptop.delete(
|
||||
f"/api/v1/sessions/{target['management_id']}",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
remaining = (await laptop.get("/api/v1/sessions")).json()["devices"]
|
||||
|
||||
assert response.status_code == 503
|
||||
assert {device["device_label"] for device in remaining} == {"Phone", "Laptop"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_protected_actions_record_only_bounded_targets(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def yes(*_args):
|
||||
return True
|
||||
|
||||
async def close(*_args):
|
||||
return {"number": 7, "state": "closed"}
|
||||
|
||||
async def merge(*_args):
|
||||
return {"number": 9, "merged": True, "state": "closed"}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
|
||||
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
csrf_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
|
||||
async def grant(action, target):
|
||||
response = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": action,
|
||||
"target": target,
|
||||
},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
return response.json()["grant"]
|
||||
|
||||
issue_grant = await grant("close_issue", "stackchain/api#7")
|
||||
closed = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": issue_grant},
|
||||
)
|
||||
pull_grant = await grant("merge_pull", "stackchain/app#9")
|
||||
merged = await client.post(
|
||||
"/api/v1/repos/stackchain/app/pulls/9/merge",
|
||||
json={"expected_head_sha": "abc123"},
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": pull_grant},
|
||||
)
|
||||
events = (await client.get("/api/v1/security-events")).json()["events"]
|
||||
|
||||
assert closed.status_code == 200
|
||||
assert merged.status_code == 200
|
||||
assert [(event["kind"], event["target"]) for event in events[:2]] == [
|
||||
("pull_merged", "stackchain/app#9"),
|
||||
("issue_closed", "stackchain/api#7"),
|
||||
]
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
assert b"abc123" not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_reservation_failure_prevents_the_gitea_mutation(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
close_calls = []
|
||||
|
||||
async def yes(*_args):
|
||||
return True
|
||||
|
||||
async def close(*args):
|
||||
close_calls.append(args)
|
||||
return {"number": 7, "state": "closed"}
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert close_calls == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_issue_close_finalization_failure_still_reports_the_closed_issue(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
async def yes(*_args):
|
||||
return True
|
||||
|
||||
async def close(*_args):
|
||||
return {"number": 7, "state": "closed"}
|
||||
|
||||
class FinalizationUnavailable:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
return "durable-operation"
|
||||
|
||||
def finalize(self, _operation_id):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", yes)
|
||||
monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "close_issue",
|
||||
"target": "stackchain/api#7",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main, "_security_event_store", lambda: FinalizationUnavailable()
|
||||
)
|
||||
response = await client.patch(
|
||||
"/api/v1/repos/stackchain/api/issues/7/close",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"number": 7, "state": "closed"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_is_journaled_before_the_session_is_removed(security_access):
|
||||
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", "device_label": "Phone"},
|
||||
)
|
||||
signed_out = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
assert signed_out.status_code == 200
|
||||
assert [(event.kind, event.device_label) for event in events[:2]] == [
|
||||
("sign_out", None),
|
||||
("sign_in", "Phone"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_reservation_failure_preserves_the_active_session(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
still_authenticated = await client.get("/api/v1/sessions")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert still_authenticated.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_finalization_failure_reports_success_and_clears_cookies(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
|
||||
class FinalizationUnavailable:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
return "durable-operation"
|
||||
|
||||
def finalize(self, _operation_id):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
main, "_security_event_store", lambda: FinalizationUnavailable()
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/session",
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
rejected = await client.get("/api/v1/sessions")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["authenticated"] is False
|
||||
assert "stackchain_session=" in response.headers["set-cookie"]
|
||||
assert rejected.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_is_journaled_after_sessions_are_removed(security_access):
|
||||
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", "device_label": "Phone"},
|
||||
)
|
||||
csrf_headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_all_sessions",
|
||||
"target": "all",
|
||||
},
|
||||
headers=csrf_headers,
|
||||
)
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={**csrf_headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
|
||||
events = SecurityEventStore(
|
||||
security_access / "security.sqlite3", clock=lambda: 0
|
||||
).list(limit=10).events
|
||||
assert response.status_code == 200
|
||||
assert events[0].kind == "all_sessions_revoked"
|
||||
assert events[0].target == "all_devices"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sign_out_all_reservation_failure_preserves_the_active_session(
|
||||
security_access, monkeypatch
|
||||
):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
grant = await client.post(
|
||||
"/api/v1/fresh-authorization",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"action": "revoke_all_sessions",
|
||||
"target": "all",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
class UnavailableJournal:
|
||||
def reserve(self, *_args, **_kwargs):
|
||||
raise SecurityEventStoreError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: UnavailableJournal())
|
||||
response = await client.delete(
|
||||
"/api/v1/sessions",
|
||||
headers={**headers, "X-Step-Up-Grant": grant.json()["grant"]},
|
||||
)
|
||||
still_authenticated = await client.get("/api/v1/sessions")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert still_authenticated.status_code == 200
|
||||
@@ -0,0 +1,86 @@
|
||||
import sqlite3
|
||||
|
||||
from src.security_event_store import SecurityEventStore
|
||||
|
||||
|
||||
def test_security_events_are_private_bounded_and_reverse_chronological(tmp_path):
|
||||
now = [1_000]
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: now[0])
|
||||
|
||||
store.record(
|
||||
"sign_in",
|
||||
method="token",
|
||||
device_label=" Timmy Phone " + "x" * 80,
|
||||
target="dashboard",
|
||||
)
|
||||
now[0] += 1
|
||||
store.record("device_revoked", device_label="Old phone", target="device")
|
||||
|
||||
page = store.list(limit=1)
|
||||
assert [(event.kind, event.device_label, event.target) for event in page.events] == [
|
||||
("device_revoked", "Old phone", "device")
|
||||
]
|
||||
assert page.next_cursor is not None
|
||||
|
||||
older = store.list(limit=10, cursor=page.next_cursor)
|
||||
assert older.events[0].kind == "sign_in"
|
||||
assert older.events[0].method == "token"
|
||||
assert older.events[0].device_label == ("Timmy Phone " + "x" * 52)
|
||||
|
||||
columns = {
|
||||
row[1] for row in sqlite3.connect(store.path).execute("PRAGMA table_info(security_events)")
|
||||
}
|
||||
assert columns == {
|
||||
"id", "kind", "method", "device_label", "target", "created_at",
|
||||
"status", "operation_id",
|
||||
}
|
||||
|
||||
|
||||
def test_security_event_retention_prunes_age_and_count(tmp_path):
|
||||
now = [0]
|
||||
store = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_events=3,
|
||||
retention_seconds=10,
|
||||
)
|
||||
for index in range(4):
|
||||
now[0] = index
|
||||
store.record("sign_in", method="token", device_label=f"Device {index}")
|
||||
|
||||
assert [event.device_label for event in store.list(limit=10).events] == [
|
||||
"Device 3", "Device 2", "Device 1"
|
||||
]
|
||||
|
||||
now[0] = 20
|
||||
store.record("sign_out", device_label="Current")
|
||||
assert [event.kind for event in store.list(limit=10).events] == ["sign_out"]
|
||||
|
||||
|
||||
def test_security_event_reservation_is_durable_until_finalized(tmp_path):
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
|
||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||
|
||||
pending = SecurityEventStore(
|
||||
tmp_path / "security.sqlite3", clock=lambda: 1_001
|
||||
).list(limit=10).events
|
||||
assert [(event.kind, event.target, event.status) for event in pending] == [
|
||||
("issue_closed", "stackchain/api#7", "pending")
|
||||
]
|
||||
|
||||
store.finalize(operation_id)
|
||||
|
||||
completed = store.list(limit=10).events
|
||||
assert [(event.kind, event.target, event.status) for event in completed] == [
|
||||
("issue_closed", "stackchain/api#7", "completed")
|
||||
]
|
||||
|
||||
|
||||
def test_failed_operation_can_discard_its_pending_reservation(tmp_path):
|
||||
store = SecurityEventStore(tmp_path / "security.sqlite3", clock=lambda: 1_000)
|
||||
operation_id = store.reserve("issue_closed", target="stackchain/api#7")
|
||||
|
||||
store.discard(operation_id)
|
||||
|
||||
assert store.list(limit=10).events == []
|
||||
@@ -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-v84" 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-v84" 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-v84" 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-v84" 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-v84" 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-v84" 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-v84" 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-v84" 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-v84" 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-v84" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -365,22 +416,29 @@ 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/update-reply-read-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 +455,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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-v84';" 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
|
||||
@@ -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"
|
||||
@@ -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-v84" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -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 & 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()
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
CONTROLLER = ROOT / "frontend" / "update-reply-read-next.js"
|
||||
SYNC = ROOT / "frontend" / "background-issue-sync.js"
|
||||
DRAFTS = ROOT / "frontend" / "drafts.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_every_unread_update_offers_one_reply_read_next_mobile_action():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="send-update-reply-read-next"' in html
|
||||
assert '>Reply, mark read & next</button>' in html
|
||||
assert "workSession.checkpointed(selectedUpdate)" not in html
|
||||
assert '<script src="static/update-reply-read-next.js"></script>' in html
|
||||
assert ".update-reply-actions button { min-height:44px; width:100%; }" in html
|
||||
|
||||
|
||||
def test_reply_read_next_orders_online_delivery_and_is_single_flight():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[]; let releaseReply;
|
||||
const controller=createController({{
|
||||
post:(item,body,id)=>{{calls.push(['reply',item.notification_id,body,id]);return new Promise(resolve=>releaseReply=resolve);}},
|
||||
markRead:id=>{{calls.push(['read',id]);return Promise.resolve({{ok:true}});}},
|
||||
queue:()=>{{throw new Error('must not queue');}}, canQueue:()=>false,
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return {{next:true}};}},
|
||||
}});
|
||||
const item={{notification_id:91}};
|
||||
const first=controller.submit(item,'Done','op-91');
|
||||
const second=controller.submit(item,'Done','op-91');
|
||||
releaseReply({{id:7}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{same:first===second,calls,results}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"same": True,
|
||||
"calls": [
|
||||
["reply", 91, "Done", "op-91"],
|
||||
["read", 91],
|
||||
["accept", 91],
|
||||
["next", 91],
|
||||
],
|
||||
"results": [
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
{"accepted": True, "delivery": "posted", "next": {"next": True}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_waits_until_the_next_update_is_open():
|
||||
script = f"""
|
||||
const createController=require({json.dumps(str(CONTROLLER))});
|
||||
let openNext;
|
||||
const controller=createController({{
|
||||
post:async()=>({{id:1}}),markRead:async()=>({{ok:true}}),queue:async()=>null,canQueue:()=>false,
|
||||
next:()=>new Promise(resolve=>openNext=()=>resolve({{notification_id:2}})),
|
||||
}});
|
||||
let settled=false;
|
||||
const result=controller.submit({{notification_id:1}},'Done','op-1').then(value=>{{settled=true;return value;}});
|
||||
setTimeout(()=>{{
|
||||
const before=settled;
|
||||
openNext();
|
||||
result.then(value=>process.stdout.write(JSON.stringify({{before,value}})));
|
||||
}},0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"before": False,
|
||||
"value": {
|
||||
"accepted": True,
|
||||
"delivery": "posted",
|
||||
"next": {"notification_id": 2},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_reply_read_next_durably_checkpoints_partial_delivery_without_reposting():
|
||||
script = f"""
|
||||
const createController = require({json.dumps(str(CONTROLLER))});
|
||||
const calls=[];
|
||||
const offline=Object.assign(new Error('offline'),{{status:503}});
|
||||
const controller=createController({{
|
||||
post:async()=>{{calls.push('reply');return{{id:8}};}},
|
||||
markRead:async()=>{{calls.push('read');throw offline;}},
|
||||
canQueue:error=>error.status===503,
|
||||
queue:async message=>{{calls.push(['queue',message]);return{{item:{{id:'saved'}},background:true}};}},
|
||||
accept:item=>calls.push(['accept',item.notification_id]),
|
||||
next:item=>{{calls.push(['next',item.notification_id]);return true;}},
|
||||
}});
|
||||
controller.submit({{notification_id:22}},'Ship it','stable-op').then(result=>
|
||||
process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
"reply",
|
||||
"read",
|
||||
["queue", {
|
||||
"kind": "update-reply-read",
|
||||
"notificationId": 22,
|
||||
"body": "Ship it",
|
||||
"operationId": "stable-op",
|
||||
"replyConfirmed": True,
|
||||
}],
|
||||
["accept", 22],
|
||||
["next", 22],
|
||||
]
|
||||
assert output["result"] == {"accepted": True, "delivery": "queued", "next": True}
|
||||
|
||||
|
||||
def test_background_retry_resumes_at_read_after_reply_checkpoint():
|
||||
item = {
|
||||
"id": "op-31", "operationId": "op-31", "ownerLogin": "timmy",
|
||||
"status": "queued", "kind": "update-reply-read", "notificationId": 31,
|
||||
"body": "Acknowledged", "replyConfirmed": True,
|
||||
}
|
||||
script = f"""
|
||||
const createSync=require({json.dumps(str(SYNC))});
|
||||
let queued={json.dumps(item)};const calls=[];
|
||||
const store={{
|
||||
claimNext:async owner=>queued?(queued=null,{{...{json.dumps(item)}}}):null,
|
||||
complete:async()=>calls.push('complete'),release:async()=>calls.push('release'),
|
||||
fail:async()=>calls.push('fail'),countBlocked:async()=>0,
|
||||
checkpoint:async()=>{{throw new Error('must not checkpoint again');}},
|
||||
}};
|
||||
const fetchJson=async(url,options={{}})=>{{calls.push([url,options.method]);return url==='api/v1/background-identity'?{{login:'timmy'}}:{{ok:true}};}};
|
||||
createSync({{store,fetchJson}}).flush().then(result=>process.stdout.write(JSON.stringify({{calls,result}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["calls"] == [
|
||||
["api/v1/background-identity", None],
|
||||
["api/v1/notifications/31/read", "PATCH"],
|
||||
"complete",
|
||||
]
|
||||
assert output["result"]["confirmed"] == [{"ok": True}]
|
||||
|
||||
|
||||
def test_partial_reply_read_delivery_is_recoverable_from_update_drafts():
|
||||
record = {"version": 2, "items": [{
|
||||
"id": "op-44", "operationId": "op-44", "ownerLogin": "timmy",
|
||||
"status": "attention", "kind": "update-reply-read", "notificationId": 44,
|
||||
"body": "Handled", "replyConfirmed": True, "error": "Read failed",
|
||||
}]}
|
||||
script = f"""
|
||||
const createDraftInbox=require({json.dumps(str(DRAFTS))});
|
||||
const values=new Map([['stackchain.authored-outbox.v1',JSON.stringify({json.dumps(record)})]]);
|
||||
const storage={{get length(){{return values.size}},key:i=>[...values.keys()][i],getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const item=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).list()[0];
|
||||
process.stdout.write(JSON.stringify(item));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["outbox_kind"] == "update-reply-read"
|
||||
assert output["title"] == "Update #44"
|
||||
assert output["route"] == {"kind": "update", "notification_id": 44}
|
||||
assert output["status"] == "attention"
|
||||
Reference in new issue
Block a user