Compare commits

...
Author SHA1 Message Date
rockachopa 6143568b95 Merge pull request 'Make mobile task overlays browser-back safe' (#316) from timmy/315-mobile-overlay-back-safety into main
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 15:56:38 +00:00
timmy 9a152c435e feat: make mobile task overlays back-safe (#315)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 15:55:17 +00:00
timmy 80a1fa60bd Merge pull request 'Preserve mobile share capture through sign-in' (#314) from timmy/313-preserve-mobile-share-sign-in into main
CI / lint (push) Successful in 31s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 15:20:01 +00:00
timmy 8ba4d48549 feat: preserve mobile shares through sign-in (#313)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 15:18:30 +00:00
timmy c0b42b71cf Merge pull request #312: Keep authentication database writes off the event loop
CI / lint (push) Successful in 32s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #311
2026-08-08 14:57:59 +00:00
timmy d026bbd400 perf: isolate authentication database work (#311)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 14:56:45 +00:00
timmy 9189d13db9 Merge pull request 'Recover safely when an active dashboard session expires' (#310) from timmy/309-session-expiry-recovery into main
CI / lint (push) Successful in 30s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 14:33:46 +00:00
timmy 9786724678 fix: recover expired dashboard sessions (#309)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 14:32:37 +00:00
rockachopa 6b99d4f171 Merge pull request 'Prevent issue content from executing shell commands in the release worker' (#308) from timmy/307-prevent-issue-content-from-executing-shell-comma into main
CI / lint (push) Successful in 30s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 14:15:08 +00:00
timmy 7d633e67e8 security: isolate release issue content from shell execution (#307)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 14:13:56 +00:00
timmy bd5e66b4e5 Merge pull request 'Recover queued delivery when background requests stall' (#306) from timmy/305-background-request-deadlines into main
CI / lint (push) Successful in 29s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 13:48:06 +00:00
timmy add45365ab fix: recover stalled background delivery (#305)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 13:46:46 +00:00
rockachopa 6138b1f24e Merge pull request 'Make live refresh self-healing when browser requests stall' (#304)
CI / lint (push) Successful in 29s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #303
2026-08-08 13:16:28 +00:00
timmy 40c66df143 fix: recover live refresh from stalled requests (#303)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 13:15:20 +00:00
rockachopa 7356530318 Merge pull request 'Capture unfiled work from a cold offline mobile launch' (#302) from timmy/301-cold-offline-capture into main
CI / lint (push) Successful in 29s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 12:40:24 +00:00
timmy 9f6476a5b4 feat: capture cold-offline work for filing (#301)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 12:38:41 +00:00
timmy de8227f4f4 Merge pull request 'Show priority work above the fold on mobile launch' (#300) from timmy/299-mobile-above-fold into main
CI / lint (push) Successful in 33s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 12:19:58 +00:00
timmy ccf5f69400 feat: show priority work above fold on mobile (#299)
CI / lint (pull_request) Successful in 31s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 12:18:24 +00:00
timmy 207df76b69 Merge pull request 'Drain closed-app queued work concurrently without failure starvation' (#298) from timmy/297-bounded-background-drain into main
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 11:57:09 +00:00
timmy 43c7a85eec perf: drain background outbox concurrently (#297)
CI / lint (pull_request) Successful in 27s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 11:55:58 +00:00
rockachopa d0b8c3dc0d Enforce a strict browser execution boundary (#296)
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 6s
Closes #295
2026-08-08 11:39:23 +00:00
timmy 6e6e63e553 security: enforce strict browser execution boundary (#295)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 11:38:18 +00:00
timmy 54029d2668 Merge pull request 'Complete private outbox purge before device sign-out' (#294) from timmy/293-complete-device-outbox-purge into main
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
2026-08-08 11:16:13 +00:00
timmy 1f57b75d5d fix: complete private outbox purge before sign-out (#293)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 4s
2026-08-08 11:15:13 +00:00
rockachopa fefbe7e084 Merge pull request 'Make Stackchain installable from the mobile dashboard' (#292) from timmy/291-mobile-install-flow into main
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-08 10:58:46 +00:00
timmy 31a07e4a46 feat: add mobile install flow
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
Closes #291
2026-08-08 10:55:35 +00:00
timmy b458dbaa78 Merge pull request 'Keep retained mobile planning available through refresh outages' (#290) from timmy/289-retained-planning into main
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 10:22:06 +00:00
timmy 8c4e729c4a fix: keep retained mobile planning available (#289)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 10:20:42 +00:00
timmy 80417abd5b Merge pull request 'Sign out all operator sessions from a lost-device safety action' (#288) from timmy/287-sign-out-all-devices into main
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 09:56:45 +00:00
timmy 37d55e9b20 feat: sign out all operator sessions (#287)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 09:55:34 +00:00
timmy 94cbde3cf9 Merge pull request 'Skip unchanged live snapshot sections end to end' (#286) from timmy/285-live-section-revisions into main
CI / lint (push) Successful in 26s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 09:37:09 +00:00
timmy fedddb246d perf: skip unchanged live snapshot sections (#285)
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 09:35:48 +00:00
timmy e28472b268 Retry edited offline issue captures with a fresh idempotency key (#284)
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 4s
Closes #283
2026-08-08 09:01:35 +00:00
timmy 3b85b4f35d fix: rotate idempotency keys after offline issue edits (#283)
CI / lint (pull_request) Successful in 29s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 09:00:21 +00:00
rockachopa 997cb7dcd7 Merge pull request 'Hand off assigned issues to teammates from mobile' (#282) from timmy/281-mobile-issue-handoff into main
CI / lint (push) Successful in 28s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 5s
2026-08-08 08:38:54 +00:00
timmy 04bb6839ae feat: hand off assigned issues from mobile (#281)
CI / lint (pull_request) Successful in 28s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 08:37:48 +00:00
rockachopa 1fdf6f417d Merge pull request 'Plan and run an ordered mobile Today queue' (#280) from timmy/279-mobile-today-queue into main
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 5s
CI / build-frontend (push) Successful in 5s
2026-08-08 08:01:26 +00:00
timmy cc38b3d0f6 feat: add ordered mobile Today queue (#279)
CI / lint (pull_request) Successful in 30s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 07:58:49 +00:00
timmy b11ec6a732 Merge pull request 'Reconcile ambiguous merge outcomes after upstream timeouts' (#278) from timmy/277-reconcile-merge-timeouts into main
CI / lint (push) Successful in 27s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
2026-08-08 07:34:43 +00:00
timmy e58808f2d4 fix: reconcile ambiguous merge outcomes (#277)
CI / lint (pull_request) Successful in 26s
CI / build-frontend (pull_request) Successful in 5s
2026-08-08 07:33:38 +00:00
timmy e0543b2007 Keep operator session validation off the event loop (#276)
CI / lint (push) Successful in 26s
Release / release-candidate (push) Successful in 4s
CI / build-frontend (push) Successful in 4s
Closes #275
2026-08-08 07:16:55 +00:00
59 changed files with 6963 additions and 3265 deletions

No files matched your search

+41 -9
View File
@@ -24,11 +24,19 @@ issue-comment API; mobile issue capture requires issue
creation and assignment permission. The New issue sheet can optionally select an open
repository milestone and due date; the dashboard validates both and sends them with
self-assignment in the single create request, so planned work appears in its release
lane immediately. Issue capture and authored mobile actions (issue
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
account-bound title/description captures without selecting a repository or entering the
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
the same Gitea login, **Choose repository** restores the capture to the normal planning
and durable delivery flow. A different or unconfirmed account can only copy or discard
the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. Results
are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
another worker replays a confirmed result instead of posting duplicate content. Closed-app
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
with the unchanged idempotency key. Device purge cancels an active drain before closing
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
writes run outside the request event loop, and lock admission is bounded to 100 ms by
@@ -84,15 +92,25 @@ that setting, a reverse proxy is safely treated as one shared source.
Each signed cookie includes an opaque session identifier whose hash and expiry are
kept in the SQLite session registry. Keep that registry on persistent, writable
storage shared by all dashboard workers. Sign-out revokes only the current session
before clearing browser state, so a copied cookie cannot be replayed afterward;
other signed-in devices remain active. Deploying this version invalidates older
cookies that do not contain a registered identifier, so operators must sign in once
again. Registry read or write failures return a sanitized HTTP 503 before Gitea is
contacted.
storage shared by all dashboard workers. If an active session expires or is revoked,
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. This recovery does not clear offline state. **Sign out & clear this device** revokes
only the current session before clearing browser state, so a copied cookie cannot
be replayed afterward; other signed-in devices remain active. **Sign out all
devices** is a separately confirmed lost-device safety action that atomically
revokes every existing operator session before clearing the current browser and
returning to sign-in. Deploying this version invalidates older cookies that do not
contain a registered identifier, so operators must sign in once again. Registry
read or write failures return a sanitized HTTP 503 before Gitea is contacted.
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
Every response also defines the browser execution boundary with a Content Security
Policy that allows scripts only from the dashboard origin, denies framing and
plugins, and blocks unused browser capabilities. Keep these response headers when
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
and stylesheet are same-origin static assets included in the offline PWA shell.
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
removing unrelated forge preferences. Rotate either dashboard secret by replacing
@@ -133,6 +151,12 @@ other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced.
Live snapshots expose independent `context`, `events`, and `notifications`
revisions. The browser sends its known revisions on later polls, so `/api/v1/live`
can omit unchanged section bodies while still returning current freshness and
retry metadata. The client retains omitted data and only rebuilds or persists the
sections that changed.
## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
@@ -241,6 +265,14 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
do not run without `--issue` when processing a preselected ticket.
Agent and test commands use POSIX argument quoting and run as argument vectors,
not through a shell. Issue context is available to the agent only through
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
text. Shell substitutions, redirects, and pipelines are intentionally not
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
configure that script as `RELEASE_AGENT_COMMAND` instead.
Durable state defaults to `.release-engine/state.json`. Full behavior and
safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
+6 -3
View File
@@ -12,8 +12,8 @@ Convert one eligible Gitea issue into a tested, traceable pull request without d
- Repository key (`owner/repo`).
- Agent username.
- Optional explicit issue number; otherwise deterministic queue selection.
- Coding-agent command template.
- Test command.
- Coding-agent command, parsed into an argument vector.
- Test command, parsed into an argument vector.
- Local repository path and durable state-file path.
## State machine
@@ -39,7 +39,10 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
## Execution
- Branch format: `<agent>/<issue>-<slug>`.
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
- Coding and test commands are parsed with POSIX argument quoting and executed directly without a shell.
- Issue number, title, body, repo, and branch are supplied to the coding agent only through `RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`, `RELEASE_REPO`, and `RELEASE_BRANCH` environment variables. Gitea-controlled content is never interpolated into executable syntax.
- Shell operators, substitutions, and pipelines are not interpreted. Operators that are intentionally required must live in a separately reviewed wrapper script configured as the command.
- Empty or malformed commands abort before the issue claim.
- Non-zero coding-agent exit blocks tests and PR creation.
- Tests run using the configured command; stdout/stderr and exit code become evidence.
+129 -11
View File
@@ -8,7 +8,14 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.result.createObjectStore('issues', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
databasePromise = undefined;
};
resolve(db);
};
request.onerror = () => reject(request.error);
});
return databasePromise;
@@ -17,7 +24,7 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return async work => {
const transact = async work => {
const db = await database();
return new Promise((resolve, reject) => {
const transaction = db.transaction('issues', 'readwrite');
@@ -38,6 +45,13 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
});
});
};
transact.close = async () => {
if (!databasePromise) return;
const db = await databasePromise;
db.close();
databasePromise = undefined;
};
return transact;
}
function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000 } = {}) {
@@ -82,6 +96,32 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
});
}
async function claimBatch(ownerLogin, limit = 70) {
return transact(async records => {
const timestamp = Number(now());
const eligible = (await records.getAll()).filter(candidate =>
candidate.recordType !== 'receipt-preference' &&
candidate.ownerLogin === ownerLogin &&
(candidate.status === 'queued' || candidate.status === 'sending') &&
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
const lanes = {
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
authored: eligible.filter(item => item.outboxLane === 'authored'),
};
const selected = [];
const maximum = Math.max(0, Number(limit) || 0);
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
if (lanes.issue.length) selected.push(lanes.issue.shift());
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift());
}
const claimed = selected.map(item => ({
...item, status: 'sending', claimUntil: timestamp + claimMs,
}));
for (const item of claimed) await records.put(item);
return claimed;
});
}
async function claim(id, ownerLogin) {
return transact(async records => {
const timestamp = Number(now());
@@ -139,6 +179,7 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
upsert,
claim,
claimNext,
claimBatch,
complete: id => update(id, item => ({ ...item, status: 'sent', claimUntil: 0 })),
release: id => update(id, item => ({ ...item, status: 'queued', claimUntil: 0 })),
fail: (id, error) => update(id, item => ({ ...item, status: 'attention', claimUntil: 0, error })),
@@ -149,10 +190,41 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
setReceiptPreference,
getReceiptPreference,
close: () => transact.close?.(),
};
}
function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
function createBackgroundIssueSync({
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
batch = work => work(), requestTimeoutMs = 15000,
}) {
let purgeRequested = false;
let activeFlush = null;
const activeRequests = new Set();
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
async function requestJson(url, options = {}) {
const controller = new AbortController();
activeRequests.add(controller);
let timer;
const interrupted = new Promise((resolve, reject) => {
controller.signal.addEventListener('abort', () => {
reject(new Error(purgeRequested
? 'Background request canceled.'
: 'Background request timed out.'));
}, { once: true });
timer = setTimeout(() => controller.abort(), timeoutMs);
});
try {
return await Promise.race([
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
interrupted,
]);
} finally {
clearTimeout(timer);
activeRequests.delete(controller);
}
}
function receiptFor(item, status, delivered = {}) {
if (status === 'attention') {
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
@@ -218,7 +290,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
async function deliver(item) {
const request = deliveryRequest(item);
try {
const delivered = await fetchJson(request.url, request.options);
const delivered = await requestJson(request.url, request.options);
await store.complete(item.id);
const receipt = receiptFor(item, 'confirmed', delivered);
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
@@ -238,6 +310,7 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
}
async function send(item, currentLogin) {
if (purgeRequested) return { blocked: true };
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
await store.upsert(item);
const claimed = await store.claim(item.id, currentLogin);
@@ -245,8 +318,8 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
return deliver(claimed);
}
async function flush() {
const identity = await fetchJson(base + 'api/v1/background-identity', {
async function runFlush() {
const identity = await requestJson(base + 'api/v1/background-identity', {
headers: { Accept: 'application/json' }, cache: 'no-store',
});
const login = String(identity?.login || '').trim();
@@ -254,21 +327,66 @@ function createBackgroundIssueSync({ store, fetchJson, base = '' }) {
const receipts = [];
let attention = 0;
if (!login) return { confirmed, blocked: 0, attention, login, receipts };
while (true) {
const item = await store.claimNext(login);
if (!item) break;
const result = await deliver(item);
const collect = result => {
if (result.issue) confirmed.push(result.issue);
if (result.message) confirmed.push(result.message);
if (result.attention) attention += 1;
if (result.receipt) receipts.push(result.receipt);
};
if (store.claimBatch) {
const claimed = await store.claimBatch(login, batchSize);
let next = 0;
let authenticationError = null;
let transientError = null;
const worker = async () => {
while (!purgeRequested && !authenticationError && next < claimed.length) {
const item = claimed[next++];
try {
collect(await deliver(item));
} catch (error) {
if (Number(error?.status || 0) === 401) authenticationError = error;
else if (!transientError) transientError = error;
}
}
};
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
await Promise.all(Array.from({ length: concurrency }, worker));
if (purgeRequested) {
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
throw new Error('Background delivery canceled.');
}
if (authenticationError) {
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
throw authenticationError;
}
if (transientError) throw transientError;
} else {
while (!purgeRequested) {
const item = await store.claimNext(login);
if (!item) break;
collect(await deliver(item));
}
}
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
return { confirmed, blocked, attention, login, receipts };
}
function flush() {
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
if (activeFlush) return activeFlush;
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
return activeFlush;
}
async function purge() {
purgeRequested = true;
activeRequests.forEach(controller => controller.abort());
if (activeFlush) await activeFlush.catch(() => {});
await store.close?.();
}
return {
flush, send,
flush, send, purge,
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
snapshot: () => store.snapshot(),
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
+66 -11
View File
@@ -5,11 +5,16 @@ function createContextPoller({
isHidden = () => false,
setTimer = setTimeout,
clearTimer = clearTimeout,
setDeadlineTimer = setTimeout,
clearDeadlineTimer = clearTimeout,
intervalMs = 8000,
timeoutMs = 12000,
}) {
let inFlight = null;
let activeRequest = null;
let timer = null;
let stopped = false;
let revisions = {};
let retainedSnapshot = null;
function cancelTimer() {
if (timer !== null) clearTimer(timer);
@@ -25,36 +30,81 @@ function createContextPoller({
}, intervalMs);
}
function refresh() {
function abortError() {
const error = new Error('Live update superseded.');
error.name = 'AbortError';
return error;
}
function supersede(request) {
if (!request || request.settled) return;
request.superseded = true;
request.controller.abort();
request.rejectDeadline(abortError());
}
function refresh(options = {}) {
if (stopped || isHidden()) return Promise.resolve(null);
if (inFlight) return inFlight;
if (activeRequest && !options.force) return activeRequest.promise;
if (activeRequest) supersede(activeRequest);
const controller = new AbortController();
let rejectDeadline;
const deadlinePromise = new Promise((resolve, reject) => {
rejectDeadline = reject;
});
const requestState = {
controller,
deadline: null,
promise: null,
rejectDeadline,
settled: false,
superseded: false,
};
activeRequest = requestState;
requestState.deadline = setDeadlineTimer(() => {
if (requestState.settled || requestState.superseded) return;
controller.abort();
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
error.name = 'TimeoutError';
rejectDeadline(error);
}, timeoutMs);
let request;
try {
request = fetchContext();
request = fetchContext({ ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
inFlight = Promise.resolve(request)
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
.then((snapshot) => {
onSnapshot(snapshot);
return snapshot;
if (activeRequest !== requestState) return retainedSnapshot;
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
revisions = { ...revisions, ...(snapshot.revisions || {}) };
onSnapshot(retainedSnapshot, changedSections);
return retainedSnapshot;
})
.catch((error) => {
onError(error);
if (activeRequest === requestState && !stopped) onError(error);
return null;
})
.finally(() => {
inFlight = null;
requestState.settled = true;
clearDeadlineTimer(requestState.deadline);
if (activeRequest !== requestState) return;
activeRequest = null;
schedule();
});
return inFlight;
return requestState.promise;
}
function setVisible(visible) {
cancelTimer();
if (!visible) return Promise.resolve(null);
return refresh();
return refresh({ force: true });
}
return {
@@ -64,6 +114,11 @@ function createContextPoller({
stop() {
stopped = true;
cancelTimer();
if (activeRequest) {
const request = activeRequest;
activeRequest = null;
supersede(request);
}
},
};
}
+309
View File
@@ -0,0 +1,309 @@
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
#sign-out-all { min-height:44px; }
button:hover { filter: brightness(1.15); }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
.panel > summary h2 { display: inline-block; margin-left: 4px; }
.panel[open] > summary { margin-bottom: 8px; }
aside, section { position: relative; z-index: 1; }
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
.sidebar { display:flex; flex-direction:column; gap: 12px; }
.stack { display:flex; flex-direction:column; gap:10px; }
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #94a3b8; }
.kv .value { color: #e5e7eb; }
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
textarea { resize: vertical; min-height: 120px; }
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
#cmd-palette.open { display: block; }
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
.small { font-size: 12px; color: #94a3b8; }
.status { display:inline-flex; gap:6px; align-items:center; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
.offline-status[hidden] { display:none; }
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
.offline-work-controls input { width:20px; height:20px; }
.offline-work-controls button { min-height:44px; }
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.install-app-card[hidden] { display:none; }
.install-app-card p { margin:0; flex:1 1 240px; }
.install-app-card button { min-height:44px; }
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
.event:last-child { border-bottom: 0; }
.my-work { grid-column: 1 / -1; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.work-settings { width:100%; }
.work-settings > summary { display:none; }
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
.work-filter { min-height: 44px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
.create-issue-actions button { min-height:44px; max-width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
.my-work-card:hover { border-color:var(--accent); }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
.detail-defer { grid-column:1/-1; max-width:100%; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.mark-update-read { min-height:44px; width:100%; }
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
.load-more-notifications[hidden] { display:none; }
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
.load-more-work[hidden] { display:none; }
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
.retry-work-route[hidden] { display:none; }
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-bulk button { min-height:44px; width:100%; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
.review-file-toggle strong { overflow-wrap:anywhere; }
.review-file.reviewed { opacity:.72; }
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
.review-mark { min-height:44px; width:100%; margin-top:8px; }
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
.review-note { min-height:88px; margin-top:6px; }
.review-feedback { display:grid; gap:8px; }
.review-feedback label { display:grid; gap:6px; }
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.review-handoff button { min-height:44px; }
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
.review-handoff-link[hidden] { display:none; }
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
#continue-review-to-merge { min-height:44px; width:100%; }
#continue-review-to-merge[hidden] { display:none; }
.review-copy-fallback { min-height:140px; }
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
.review-progress-actions button { min-height:44px; max-width:100%; }
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
.review-diff-line { display:block; min-width:max-content; }
.review-inline-target { min-height:44px; width:100%; padding:8px; border:0; border-radius:0; text-align:left; font:inherit; white-space:pre; }
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
.review-inline-composer[hidden] { display:none; }
.review-inline-composer textarea { min-height:96px; width:100%; }
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.review-inline-actions button { min-height:44px; }
.review-diff-line.hunk { color:#93c5fd; }
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
.review-action { min-height:44px; }
.review-retry { min-height:44px; margin-top:10px; }
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.update-sheet.open { display:flex; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
.update-reply button { min-height:44px; width:100%; }
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.issue-sheet.open { display:flex; }
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.issue-sheet-header button { min-height:44px; }
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
.issue-planning-retry { min-height:44px; width:100%; }
#edit-issue-content { min-height:44px; width:100%; }
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
.issue-edit-form textarea { min-height:132px; resize:vertical; }
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
.issue-comment-composer button { min-height:44px; width:100%; }
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.search-preview.open { display:flex; }
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.create-issue-label-option input { width:20px; height:20px; margin:0; }
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.shared-content-actions button { min-height:44px; }
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.pull-sheet.open { display:flex; }
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
.pull-review-file { margin-top:8px; }
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
.pull-diff-line { display:block; width:max-content; min-width:100%; }
.pull-diff-line.added { color:#86efac; background:#123422; }
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
.pull-review-tools button { min-height:44px; }
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
.pull-review summary h2 { margin:0; font-size:16px; }
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
.mobile-task-dock { display:none; }
.mobile-task-dock[hidden] { display:none; }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
.app-menu { position:relative; margin-left:0; }
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
.app-menu > summary::-webkit-details-marker { display:none; }
.app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; }
.my-work { margin:0; }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.my-work-list { grid-template-columns:1fr; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
.update-sheet-panel { width:100%; border-left:0; padding:14px; }
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
.create-issue-panel { width:100%; border-left:0; padding:14px; }
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -11,7 +11,11 @@ function createDetailDefer({
defer(item, preset) {
if (!item) return false;
const until = laterWork.presetUntil(preset);
if (!laterWork.defer(item, until)) return false;
const result = laterWork.defer(item, until);
if (result !== 'deferred') {
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
return false;
}
const inSession = session.active();
if (!inSession) close();
refresh();
+70 -3123
View File
File diff suppressed because it is too large. Load diff
+62
View File
@@ -0,0 +1,62 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createInstallApp = factory;
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
const dismissedKey = 'stackchain.install.dismissed.v1';
let deferredPrompt = null;
function hide() {
options.card.hidden = true;
}
function showNativePrompt(event) {
event.preventDefault();
deferredPrompt = event;
options.guidance.hidden = true;
options.installButton.hidden = false;
options.card.hidden = false;
}
async function install() {
if (!deferredPrompt) return;
const prompt = deferredPrompt;
deferredPrompt = null;
options.installButton.disabled = true;
await prompt.prompt();
const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') {
hide();
options.status.textContent = 'Stackchain was added to your device.';
} else {
hide();
options.installButton.disabled = false;
options.status.textContent = 'Installation was not completed.';
}
}
function dismiss() {
options.storage.setItem(dismissedKey, '1');
hide();
}
function start() {
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
hide();
return;
}
if (options.isIosSafari()) {
options.installButton.hidden = true;
options.guidance.hidden = false;
options.card.hidden = false;
}
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
options.window.addEventListener('appinstalled', () => {
deferredPrompt = null;
hide();
});
options.installButton.addEventListener('click', install);
options.dismissButton.addEventListener('click', dismiss);
}
return {start};
});
+17 -3
View File
@@ -67,13 +67,27 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
let updated = null;
write(read().map(item => {
if (item.id !== id) return item;
const nextRepository = String(draft?.repository || '');
const nextTitle = String(draft?.title || '');
const nextBody = String(draft?.body || '');
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
? Number(draft.milestoneId) : undefined;
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
? String(draft.dueDate) : undefined;
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate;
updated = {
...item,
repository: String(draft?.repository || ''), title: String(draft?.title || ''),
body: String(draft?.body || ''),
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
repository: nextRepository, title: nextTitle,
body: nextBody, labelIds: nextLabelIds,
milestoneId: nextMilestoneId, dueDate: nextDueDate,
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
if (nextMilestoneId === undefined) delete updated.milestoneId;
if (nextDueDate === undefined) delete updated.dueDate;
delete updated.error;
return updated;
}), mirror);
+26
View File
@@ -22,6 +22,7 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
let commentRequest = null;
let closeRequest = null;
let releaseRequest = null;
let handoffRequest = null;
let labelRequest = null;
let editRequest = null;
let dueDateRequest = null;
@@ -61,6 +62,11 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
headers: { Accept: 'application/json' },
});
},
loadHandoffCandidates(item) {
return fetchJson(issuePath(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
});
},
loadDraft(item) {
try { return storage?.getItem(draftKey(item)) || ''; }
catch (_error) { return ''; }
@@ -209,6 +215,26 @@ function createIssueSheet({ fetchJson, storage, createConversationPager = global
}).finally(() => { releaseRequest = null; });
return releaseRequest;
},
handoff(item, recipient, currentLogin) {
if (handoffRequest) return handoffRequest;
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient }),
}).then(result => {
if (
result?.number !== item.number ||
result?.recipient !== recipient ||
!Array.isArray(result.assignees) ||
!result.assignees.includes(recipient) ||
result.assignees.includes(currentLogin)
) {
throw new Error('Issue handoff was not confirmed.');
}
return result;
}).finally(() => { handoffRequest = null; });
return handoffRequest;
},
comment(item, body) {
if (commentRequest) return commentRequest;
this.saveDraft(item, body);
+3 -3
View File
@@ -42,11 +42,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const key = storageKey();
const id = identity(item);
const wake = new Date(until);
if (!key || !id || Number.isNaN(wake.getTime()) || wake <= now()) return false;
if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
records[id] = wake.toISOString();
write(records);
return true;
return write(records) ? 'deferred' : 'unavailable';
}
function presetUntil(preset) {
+28 -2
View File
@@ -2,15 +2,38 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createLoginController = factory;
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
const invalid = entries.some(([name, content]) => (
!Object.prototype.hasOwnProperty.call(limits, name)
|| !content
|| content.length > limits[name]
));
if (invalid) return './';
return value;
}
const form = options.form;
const status = options.status;
const button = options.button;
const fetchImpl = options.fetchImpl;
const location = options.location;
const continuation = validShareContinuation(options.continuation);
const setIntervalImpl = options.setIntervalImpl || setInterval;
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null;
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
function showReason(reason) {
if (reason !== 'session-expired') return;
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
}
function showRetryCountdown(seconds) {
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
button.disabled = true;
@@ -44,7 +67,7 @@
}
form.reset();
if (response.ok) {
location.replace('./');
location.replace(continuation);
return;
}
if (response.status === 429) {
@@ -54,20 +77,23 @@
status.textContent = 'Sign-in failed. Check the token and try again.';
}
return { submit };
return { submit, showReason };
}));
if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in');
const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in');
const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({
form,
status,
button,
fetchImpl: fetch.bind(window),
location: window.location,
continuation: loginParams.get('continue'),
});
controller.showReason(loginParams.get('reason'));
form.addEventListener('submit', event => {
event.preventDefault();
const accessToken = new FormData(form).get('access_token');
+32
View File
@@ -0,0 +1,32 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.mobileLaunch = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const allowedFilters = new Set([
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
]);
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
if (allowedFilters.has(saved)) return saved;
if (today > 0) return 'today';
if (attention > 0) return 'attention';
return 'all';
}
function createDisclosure({ disclosure, launcher }) {
function onKeydown(event) {
if (event.key !== 'Escape' || !disclosure.open) return;
event.preventDefault();
disclosure.open = false;
launcher.focus();
}
return {
start() {
disclosure.addEventListener('keydown', onKeydown);
},
};
}
return { chooseFilter, createDisclosure };
});
+5 -1
View File
@@ -139,7 +139,11 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
}).then(result => {
if (!result?.merged) throw new Error('Pull request merge was not confirmed.');
if (!result?.merged) {
throw new Error(result?.confirmation_pending && result?.error
? result.error
: 'Pull request merge was not confirmed.');
}
return result;
}).finally(() => { mergeRequest = null; });
return mergeRequest;
+47 -5
View File
@@ -1,10 +1,12 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v19';
const CACHE = 'stackchain-dashboard-shell-v32';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
@@ -13,11 +15,13 @@ const SHELL = [
BASE + 'static/search-preview.js',
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
BASE + 'static/offline-work.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
@@ -29,24 +33,52 @@ const SHELL = [
BASE + 'static/work-route.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/background-issue-sync.js',
];
async function sessionCsrf() {
async function sessionCsrf(signal) {
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) return '';
const payload = await response.json().catch(() => ({}));
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
}
let batchedCsrf = null;
let batchedCsrfController = null;
async function withSessionCsrf(work) {
batchedCsrfController = new AbortController();
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
try {
return await work();
} finally {
batchedCsrfController.abort();
batchedCsrf = null;
batchedCsrfController = null;
}
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const headers = new Headers(options.headers || {});
const csrf = await sessionCsrf();
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
if (batchedCsrf && options.signal) {
if (options.signal.aborted) cancelBatchedCsrf();
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
}
let csrf;
try {
csrf = await (batchedCsrf || sessionCsrf(options.signal));
} finally {
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
}
if (options.signal?.aborted) throw new Error('Background request aborted.');
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
@@ -61,7 +93,7 @@ async function fetchJson(url, options = {}) {
}
const issueSync = self.__issueSync || createBackgroundIssueSync({
store: createIssueSyncStore(), fetchJson, base: BASE,
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
});
async function flushAndNotify() {
@@ -98,6 +130,14 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
}
})());
});
self.addEventListener('notificationclick', event => {
@@ -123,7 +163,9 @@ self.addEventListener('fetch', event => {
event.respondWith(
fetch(request).then(async response => {
const cache = await caches.open(CACHE);
if (response.ok) {
const responseUrl = new URL(response.url || request.url);
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
+62 -5
View File
@@ -13,13 +13,21 @@
indexedDB: root.indexedDB,
caches: root.caches,
serviceWorker: root.navigator?.serviceWorker,
MessageChannel: root.MessageChannel,
location: root.location,
confirmAction: message => root.confirm(message),
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
onClearError: error => {
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
},
});
root.fetch = boundary.fetch;
const attach = () => {
const button = root.document.getElementById('sign-out');
if (button) button.addEventListener('click', () => boundary.signOut());
const allDevicesButton = root.document.getElementById('sign-out-all');
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
boundary.resumeQueuedWork();
};
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
@@ -27,10 +35,12 @@
root.stackchainSession = boundary;
}
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, location,
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction,
onExpired = () => {},
onClearError = () => {},
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
function csrfToken() {
const entry = String(cookie?.() || '').split(';')
@@ -54,7 +64,11 @@
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
if (response.status === 401) onExpired();
if (response.status === 401 && isSameOrigin(input) && !expirationStarted) {
expirationStarted = true;
onExpired();
location.replace(base + 'login?reason=session-expired');
}
return response;
}
@@ -70,11 +84,44 @@
} catch (_error) { /* Cookie invalidation still protects server data. */ }
}
async function stopWorkerOutbox() {
const registration = await serviceWorker?.ready;
if (!registration?.active || !MessageChannel) return;
await new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
channel.port1.onmessage = event => {
clearTimeout(timeout);
if (event.data?.ok) resolve();
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
};
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
});
}
function deletePrivateOutbox() {
if (!indexedDB) return Promise.resolve();
return new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase('stackchain-background-outbox-v1'); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function clearPrivateDeviceData() {
removeDashboardStorage(localStorage);
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
try { indexedDB?.deleteDatabase('stackchain-background-outbox-v1'); }
catch (_error) { /* Continue clearing other dashboard state. */ }
try {
await stopWorkerOutbox();
await deletePrivateOutbox();
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
throw error;
}
try {
const keys = await caches?.keys?.() || [];
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
@@ -90,6 +137,16 @@
}
}
async function signOutAllDevices() {
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
if (!confirmed) return false;
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
if (!response.ok) throw new Error('Could not sign out all devices');
await clearPrivateDeviceData();
location.assign(base + 'login');
return true;
}
async function resumeQueuedWork() {
try {
const registration = await serviceWorker?.ready;
@@ -97,5 +154,5 @@
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
}
return { fetch: sessionFetch, signOut, clearPrivateDeviceData, resumeQueuedWork };
return { fetch: sessionFetch, signOut, signOutAllDevices, clearPrivateDeviceData, resumeQueuedWork };
});
+60
View File
@@ -0,0 +1,60 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createTaskOverlayHistory = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
let started = false;
function stateKind(state = history.state) {
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
}
function apply(state) {
const next = stateKind(state);
if (next === active) return;
const previous = active;
active = next;
onChange(next, previous);
}
return {
start() {
if (started) return;
started = true;
eventTarget.addEventListener('popstate', event => apply(event.state));
},
open(kind) {
if (!allowed.has(kind)) return false;
if (active === kind) return true;
const previous = active;
const state = { ...(history.state || {}), taskOverlay: kind };
history.pushState(state, '');
active = kind;
onChange(kind, previous);
return true;
},
close() {
if (!active) return false;
history.back();
return true;
},
leave() {
if (!active) return false;
const previous = active;
const state = { ...(history.state || {}) };
delete state.taskOverlay;
history.replaceState(state, '');
active = null;
onChange(null, previous);
return true;
},
current() { return active; },
};
};
});
+91
View File
@@ -0,0 +1,91 @@
function createTodayWork({ storage, getLogin, limit = 5 }) {
const prefix = 'stackchain.today-work.v1.';
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function storageKey() {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? prefix + encodeURIComponent(login) : '';
}
function read() {
const key = storageKey();
if (!key || !storage) return [];
try {
const value = JSON.parse(storage.getItem(key) || '[]');
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
} catch (_error) {
return [];
}
}
function write(ids) {
const key = storageKey();
if (!key || !storage) return false;
try {
if (ids.length) storage.setItem(key, JSON.stringify(ids));
else storage.removeItem(key);
return true;
} catch (_error) {
return false;
}
}
function add(item) {
if (!storageKey()) return 'unavailable';
const id = identity(item);
if (!id) return 'unavailable';
const ids = read();
if (ids.includes(id)) return 'exists';
if (ids.length >= limit) return 'full';
ids.push(id);
return write(ids) ? 'added' : 'unavailable';
}
function remove(item) {
const id = identity(item);
const ids = read();
const next = ids.filter(candidate => candidate !== id);
return next.length !== ids.length && write(next);
}
function move(item, direction) {
const ids = read();
const index = ids.indexOf(identity(item));
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
if (index < 0 || target < 0 || target >= ids.length) return false;
[ids[index], ids[target]] = [ids[target], ids[index]];
return write(ids);
}
function reconcile(items, { pruneMissing = false } = {}) {
const available = new Map((items || []).map(item => [identity(item), item]));
const ids = read();
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
if (retained.length !== ids.length) write(retained);
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
}
function contains(item) {
return read().includes(identity(item));
}
function position(item) {
const ids = read();
const index = ids.indexOf(identity(item));
return {
can_up: index > 0,
can_down: index >= 0 && index < ids.length - 1,
};
}
return { identity, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+65
View File
@@ -0,0 +1,65 @@
function createUnfiledCaptures({
storage,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
now = () => Date.now(),
maxItems = 20,
}) {
const storageKey = 'stackchain.unfiled-issues.v1';
function read() {
try {
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
return record.items.filter(item =>
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
);
} catch (_error) { return []; }
}
function write(items) {
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
}
function list() {
const currentLogin = String(getCurrentLogin() || '').trim();
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function save(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
write(items);
return item;
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
return true;
}
function resume(id, confirmedLogin) {
const item = read().find(candidate => candidate.id === id);
if (!item) throw new Error('This capture is no longer available.');
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
discard(id);
return {repository:'', title:item.title, body:item.body, labelIds:[]};
}
return {list, save, discard, resume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+4
View File
@@ -140,6 +140,10 @@ def revoke_session(session: Session) -> None:
_session_store().revoke(session.session_id)
async def revoke_all_sessions() -> None:
await asyncio.to_thread(_session_store().revoke_all)
async def request_session(request: Request) -> Session | None:
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
+99
View File
@@ -1038,6 +1038,90 @@ async def release_assigned_issue(repository: str, number: int) -> dict:
}
async def issue_handoff_candidates(repository: str) -> list[dict]:
user, response = await asyncio.gather(
current_user(),
_get_client().get(
f"/api/v1/repos/{repository}/assignees", headers=_auth()
),
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea assignees response was not a list")
current_login = user.get("login") if isinstance(user, dict) else None
candidates = []
for item in payload:
if not isinstance(item, dict):
continue
login = item.get("login")
if not isinstance(login, str) or not login or login == current_login:
continue
full_name = item.get("full_name")
candidates.append({
"login": login,
"name": full_name if isinstance(full_name, str) and full_name else login,
})
return candidates
async def handoff_assigned_issue(
repository: str, number: int, recipient: str
) -> dict:
login, issue = await _current_login_and_target(
f"repos/{repository}/issues/{number}"
)
if (
issue.get("state") != "open"
or issue.get("pull_request") is not None
or not _login_in_users(login, issue.get("assignees"))
):
raise IssueNotAvailableError("Issue is not assigned to the current user")
eligible = {
item["login"] for item in await issue_handoff_candidates(repository)
}
if recipient not in eligible:
raise IssueNotAvailableError("Handoff recipient is not eligible")
assignees_value = issue.get("assignees")
assignees = assignees_value if isinstance(assignees_value, list) else []
desired = [
item["login"] for item in assignees
if isinstance(item, dict)
and isinstance(item.get("login"), str)
and item["login"] != login
]
if recipient not in desired:
desired.append(recipient)
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": desired},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
item["login"] for item in confirmed_value
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login in confirmed_assignees
or recipient not in confirmed_assignees
or set(confirmed_assignees) != set(desired)
):
raise ValueError("Gitea did not confirm issue handoff")
return {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
"recipient": recipient,
}
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
@@ -1470,6 +1554,21 @@ async def pull_completion_review(repository: str, number: int) -> dict:
}
async def is_pull_merged_at_head(
repository: str, number: int, expected_head_sha: str
) -> bool:
pull = await fetch(f"repos/{repository}/pulls/{number}")
if not isinstance(pull, dict):
return False
head_value = pull.get("head")
head = head_value if isinstance(head_value, dict) else {}
return (
pull.get("merged") is True
and pull.get("state") == "closed"
and head.get("sha") == expected_head_sha
)
async def merge_assigned_pull(
repository: str, number: int, expected_head_sha: str
) -> dict:
+225 -13
View File
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlencode
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
@@ -98,6 +99,9 @@ _live_section_retry_at: dict[str, float | None] = {
section: None for section in LIVE_SNAPSHOT_SECTIONS
}
_live_snapshot_refreshing_sections: set[str] = set()
_live_section_revisions: dict[str, int] = {
section: 0 for section in LIVE_SNAPSHOT_SECTIONS
}
_read_notification_ids: set[int] = set()
_authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float]
@@ -239,6 +243,12 @@ class IssueMilestoneUpdate(BaseModel):
milestone_id: PositiveInt | None = None
class IssueHandoff(BaseModel):
recipient: str = Field(
min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.-]+$"
)
class PullReviewComment(BaseModel):
path: str = Field(min_length=1, max_length=1_000)
body: str = Field(min_length=1, max_length=10_000)
@@ -443,6 +453,24 @@ app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
app.include_router(frontend_router)
def _share_target_login_redirect(request: Request) -> str:
limits = {"title": 200, "text": 8000, "url": 2048}
if any(
len(request.query_params.get(name, "")) > limit
for name, limit in limits.items()
):
return "login"
shared = [
(name, request.query_params[name])
for name in limits
if request.query_params.get(name)
]
if dashboard_auth.application_path(request) != "/" or not shared:
return "login"
continuation = f"./?{urlencode(shared)}"
return f"login?{urlencode({'continue': continuation})}"
@app.middleware("http")
async def require_operator_session(request: Request, call_next):
path = dashboard_auth.application_path(request)
@@ -487,7 +515,11 @@ async def require_operator_session(request: Request, call_next):
status_code=401,
headers={"Cache-Control": "no-store"},
)
return RedirectResponse("login", status_code=303, headers={"Cache-Control": "no-store"})
return RedirectResponse(
_share_target_login_redirect(request),
status_code=303,
headers={"Cache-Control": "no-store"},
)
if not public and request.method not in {"GET", "HEAD", "OPTIONS"}:
supplied_csrf = request.headers.get("x-csrf-token", "")
@@ -530,6 +562,37 @@ async def prevent_live_api_caching(request, call_next):
return response
CONTENT_SECURITY_POLICY = "; ".join(
(
"default-src 'self'",
"script-src 'self'",
"connect-src 'self'",
"img-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"worker-src 'self'",
"manifest-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
)
)
@app.middleware("http")
async def enforce_browser_security_boundary(request: Request, call_next):
"""Apply one browser trust boundary, including to auth short-circuits."""
response = await call_next(request)
response.headers["Content-Security-Policy"] = CONTENT_SECURITY_POLICY
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = (
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
)
response.headers["X-Frame-Options"] = "DENY"
return response
@app.get("/healthz")
def health() -> dict[str, str]:
"""Return process liveness without depending on Gitea."""
@@ -546,7 +609,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
)
attempts = _login_attempt_store()
try:
retry_after = attempts.retry_after(source)
retry_after = await asyncio.to_thread(attempts.retry_after, source)
except LoginAttemptStoreError:
return JSONResponse(
{"detail": "Sign-in throttling is temporarily unavailable"},
@@ -567,7 +630,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
payload.access_token, configured_token
):
try:
attempts.record_failure(source)
await asyncio.to_thread(attempts.record_failure, source)
except LoginAttemptStoreError:
return JSONResponse(
{"detail": "Sign-in throttling is temporarily unavailable"},
@@ -576,7 +639,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
)
raise HTTPException(status_code=401, detail="Invalid access token")
try:
attempts.clear(source)
await asyncio.to_thread(attempts.clear, source)
except LoginAttemptStoreError:
return JSONResponse(
{"detail": "Sign-in throttling is temporarily unavailable"},
@@ -584,7 +647,7 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
headers={"Cache-Control": "no-store"},
)
try:
signed, session = dashboard_auth.issue_session()
signed, session = await asyncio.to_thread(dashboard_auth.issue_session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
@@ -628,7 +691,7 @@ async def session_status(request: Request):
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
try:
dashboard_auth.revoke_session(session)
await asyncio.to_thread(dashboard_auth.revoke_session, session)
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
@@ -654,6 +717,39 @@ async def sign_out(request: Request, response: Response):
return {"authenticated": False, "clear_private_device_data": True}
@app.delete("/api/v1/sessions")
async def sign_out_all_devices(request: Request, response: Response):
try:
await dashboard_auth.revoke_all_sessions()
except dashboard_auth.SessionStoreError:
return JSONResponse(
{"detail": "Session registry is temporarily unavailable"},
status_code=503,
headers={"Cache-Control": "no-store"},
)
path = dashboard_auth.cookie_path(request)
response.delete_cookie(
dashboard_auth.SESSION_COOKIE,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.delete_cookie(
dashboard_auth.CSRF_COOKIE,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {
"authenticated": False,
"all_sessions_revoked": True,
"clear_private_device_data": True,
}
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
@@ -1076,8 +1172,19 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict:
_live_section_retry_at[section] = None
else:
_record_live_section_failure(section)
result = _merge_live_snapshot(_live_snapshot_value, refreshed)
previous = _live_snapshot_value
result = _merge_live_snapshot(previous, refreshed)
result = _without_read_notifications(result)
for section in sections:
if section not in refreshed_states:
continue
changed = previous is None or previous.get(section) != result.get(section)
if section == "notifications":
changed = changed or previous is None or previous.get(
"notification_pagination"
) != result.get("notification_pagination")
if changed:
_live_section_revisions[section] += 1
_live_snapshot_value = result
successful_times = [value for value in _live_section_created_at.values() if value is not None]
_live_snapshot_created_at = max(successful_times) if successful_times else None
@@ -1100,7 +1207,13 @@ def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task:
return _live_snapshot_task
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
def _live_snapshot_payload(
value: dict,
*,
stale: bool,
revalidating: bool,
known_revisions: dict[str, int | None] | None = None,
) -> dict:
payload = dict(value)
now = time.monotonic()
section_freshness = {}
@@ -1132,6 +1245,13 @@ def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> d
"retry_in_seconds": min(retries, default=0),
"sections": section_freshness,
}
payload["revisions"] = dict(_live_section_revisions)
for section, known_revision in (known_revisions or {}).items():
if known_revision is None or known_revision != _live_section_revisions[section]:
continue
payload.pop(section, None)
if section == "notifications":
payload.pop("notification_pagination", None)
return payload
@@ -1149,6 +1269,8 @@ def _remove_notification_from_live_snapshot(thread_id: int) -> None:
for notification in retained_notifications
if not isinstance(notification, dict) or notification.get("id") != thread_id
]
if updated["notifications"] != retained_notifications:
_live_section_revisions["notifications"] += 1
_live_snapshot_value = updated
@@ -1174,9 +1296,18 @@ def _without_read_notifications(snapshot: dict) -> dict:
@app.get("/api/v1/live")
async def live_snapshot() -> JSONResponse:
async def live_snapshot(
context_revision: int | None = Query(default=None, ge=0),
events_revision: int | None = Query(default=None, ge=0),
notifications_revision: int | None = Query(default=None, ge=0),
) -> JSONResponse:
"""Return a freshness-bounded snapshot and share identical upstream loads."""
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
known_revisions = {
"context": context_revision,
"events": events_revision,
"notifications": notifications_revision,
}
now = time.monotonic()
due_sections = _due_live_sections(now)
if (
@@ -1191,19 +1322,28 @@ async def live_snapshot() -> JSONResponse:
for state in _live_snapshot_value.get("sections", {}).values()
),
revalidating=False,
known_revisions=known_revisions,
)
)
task = _start_live_snapshot_refresh(due_sections)
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True
_live_snapshot_value,
stale=True,
revalidating=True,
known_revisions=known_revisions,
)
)
try:
result = await asyncio.shield(task)
return JSONResponse(
_live_snapshot_payload(result, stale=False, revalidating=False)
_live_snapshot_payload(
result,
stale=False,
revalidating=False,
known_revisions=known_revisions,
)
)
except TimeoutError:
return JSONResponse(
@@ -1478,6 +1618,61 @@ async def release_assigned_issue(
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff-candidates")
async def issue_handoff_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
async def load_candidates():
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await gitea_proxy.issue_handoff_candidates(repository)
try:
result = await asyncio.wait_for(
load_candidates(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Teammates could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/handoff")
async def handoff_assigned_issue(
handoff: IssueHandoff,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
result = await asyncio.wait_for(
gitea_proxy.handoff_assigned_issue(
repository, number, handoff.recipient
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The issue or recipient changed. Reload before handing off."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/issues/{number}/detail")
async def assigned_issue_detail(owner: str, repo: str, number: int = PathParam(gt=0)):
repository = f"{owner}/{repo}"
@@ -2068,9 +2263,26 @@ async def merge_assigned_pull(
status_code=409,
)
except Exception:
try:
merged = await asyncio.wait_for(
gitea_proxy.is_pull_merged_at_head(
repository, number, submission.expected_head_sha
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except Exception:
merged = False
if merged:
return {"number": number, "merged": True, "state": "closed"}
return JSONResponse(
{"error": "The pull request could not be merged. It remains in My Work; please retry."},
status_code=503,
{
"number": number,
"merged": False,
"state": "unknown",
"confirmation_pending": True,
"error": "Merge confirmation is pending. Check its status before retrying.",
},
status_code=202,
headers={"Retry-After": "1"},
)
+20 -15
View File
@@ -63,7 +63,7 @@ class Client(Protocol):
class Runner(Protocol):
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
def run(self, command: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult: ...
class StateStore:
@@ -113,8 +113,18 @@ def branch_name(agent: str, issue: Issue) -> str:
return f"{actor}/{issue.number}-{slug or 'issue'}"
def parse_command(command: str, name: str) -> list[str]:
try:
arguments = shlex.split(command)
except ValueError as exc:
raise ValueError(f"invalid {name}: {exc}") from exc
if not arguments:
raise ValueError(f"invalid {name}: command is empty")
return arguments
class ShellRunner:
def run(self, command: str, cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
def run(self, command: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult:
merged_env = os.environ.copy()
if env:
merged_env.update(env)
@@ -122,7 +132,7 @@ class ShellRunner:
command,
cwd=str(cwd),
env=merged_env,
shell=True,
shell=False,
text=True,
capture_output=True,
)
@@ -220,6 +230,9 @@ class ReleaseEngine:
"agent": self.config.agent,
}
agent_command = parse_command(self.config.agent_command, "agent command")
test_command = parse_command(self.config.test_command, "test command")
self.client.claim_issue(self.config.repo, issue.number, self.config.agent)
verified = self.client.get_issue(self.config.repo, issue.number)
if verified.assignee != self.config.agent:
@@ -228,19 +241,11 @@ class ReleaseEngine:
)
self.store.save(RunState(issue.number, branch, "claimed"))
branch_result = self.runner.run(f"git checkout -B {shlex.quote(branch)}", self.config.repo_path)
branch_result = self.runner.run(["git", "checkout", "-B", branch], self.config.repo_path)
if branch_result.returncode != 0:
raise RuntimeError(f"branch creation failed: {branch_result.stderr.strip()}")
context = {
"issue_number": issue.number,
"issue_title": issue.title,
"issue_body": issue.body,
"repo": self.config.repo,
"branch": branch,
}
command = self.config.agent_command.format(**context)
agent_result = self.runner.run(command, self.config.repo_path, {
agent_result = self.runner.run(agent_command, self.config.repo_path, {
"RELEASE_ISSUE_NUMBER": str(issue.number),
"RELEASE_ISSUE_TITLE": issue.title,
"RELEASE_ISSUE_BODY": issue.body,
@@ -252,14 +257,14 @@ class ReleaseEngine:
raise RuntimeError(f"coding agent failed: {agent_result.stderr.strip()}")
self.store.save(RunState(issue.number, branch, "agent_complete"))
test_result = self.runner.run(self.config.test_command, self.config.repo_path)
test_result = self.runner.run(test_command, self.config.repo_path)
evidence = (test_result.stdout + "\n" + test_result.stderr).strip()[-4000:]
if test_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "tests_failed", evidence))
raise RuntimeError(f"tests failed: {evidence}")
self.store.save(RunState(issue.number, branch, "tests_passed", evidence))
push_result = self.runner.run(f"git push -u origin {shlex.quote(branch)}", self.config.repo_path)
push_result = self.runner.run(["git", "push", "-u", "origin", branch], self.config.repo_path)
if push_result.returncode != 0:
self.store.save(RunState(issue.number, branch, "push_failed", push_result.stderr[-2000:]))
raise RuntimeError(f"push failed: {push_result.stderr.strip()}")
+7
View File
@@ -86,3 +86,10 @@ class SessionStore:
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def revoke_all(self) -> None:
try:
with self._connect() as connection:
connection.execute("DELETE FROM active_sessions")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
+1 -1
View File
@@ -13,7 +13,7 @@ LOGIN_HTML = """<!doctype html>
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status"></p></form></main>
<form id="sign-in"><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/login.js"></script></body></html>"""
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from src.views import dashboard as dashboard_html
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
def dashboard_bundle_text() -> str:
"""Return the HTML, CSS, and bootstrap sources that make up the dashboard."""
return "\n".join(
(
(FRONTEND / "index.html").read_text(),
(FRONTEND / "dashboard.css").read_text(),
(FRONTEND / "dashboard.js").read_text(),
)
)
async def dashboard() -> str:
# Exercise the real view lookup before adding its separately served assets.
html = await dashboard_html()
return "\n".join(
(
html,
(FRONTEND / "dashboard.css").read_text(),
(FRONTEND / "dashboard.js").read_text(),
)
)
+2 -3
View File
@@ -2,12 +2,11 @@ import re
from pathlib import Path
from urllib.parse import urljoin
DASHBOARD_HTML = Path(__file__).parent.parent / "frontend" / "index.html"
from tests.dashboard_bundle import dashboard_bundle_text
def test_api_requests_resolve_inside_dashboard_subpath():
html = DASHBOARD_HTML.read_text()
html = dashboard_bundle_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
assert api_paths
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "authored-outbox.js"
+231 -1
View File
@@ -6,7 +6,7 @@ import httpx
import pytest
from src import main
from src.views import dashboard
from tests.dashboard_bundle import dashboard
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
@@ -194,6 +194,39 @@ const transaction=work=>{{const run=tail.then(()=>work({{
]
def test_indexeddb_store_closes_on_version_change_and_reopens_afterward():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const state={{opens:0,closes:0}};
const indexedDB={{lastDb:null,open:()=>{{
state.opens += 1;
const request={{result:null,onsuccess:null,onerror:null,onupgradeneeded:null}};
const db={{
objectStoreNames:{{contains:()=>true}},
close:()=>{{state.closes += 1;}},
transaction:()=>{{
const tx={{oncomplete:null,onerror:null,onabort:null,error:null,objectStore:()=>({{
getAll:()=>{{const get={{onsuccess:null,onerror:null,result:[]}};queueMicrotask(()=>get.onsuccess?.());return get;}},
}})}};
setTimeout(()=>tx.oncomplete?.(),0);
return tx;
}},
}};
indexedDB.lastDb=db;request.result=db;queueMicrotask(()=>request.onsuccess?.());return request;
}}}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{indexedDB}});
await store.snapshot();
indexedDB.lastDb.onversionchange?.();
await store.snapshot();
process.stdout.write(JSON.stringify(state));
}})();
"""
output = run_node(script)
assert output == {"opens": 2, "closes": 1}
def test_delivery_receipt_preference_is_account_bound_and_hidden_from_outbox():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
@@ -326,6 +359,203 @@ const fetchJson = async url => {{
assert output["error"] == "Gitea unavailable"
def test_stalled_delivery_times_out_releases_claim_and_retries_with_same_idempotency_key():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const item = {{id:'capture-timeout',operationId:'stable-operation',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
const state = {{queued:true,released:[],completed:[],keys:[],attempts:0}};
const store = {{
claimNext: async () => state.queued ? (state.queued=false, {{...item}}) : null,
complete: async id => state.completed.push(id),
release: async id => {{state.released.push(id); state.queued=true;}},
fail: async () => {{}}, countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
state.keys.push(options.headers['Idempotency-Key']);
state.attempts += 1;
if (state.attempts === 1) return new Promise(() => {{}});
return {{repository:'o/r',number:9}};
}};
(async () => {{
const sync=createBackgroundIssueSync({{store,fetchJson,requestTimeoutMs:10}});
let firstError='';
try {{ await sync.flush(); }} catch (error) {{ firstError=error.message; }}
const second=await sync.flush();
process.stdout.write(JSON.stringify({{state,firstError,second}}));
}})();
"""
output = run_node(script)
assert output["firstError"] == "Background request timed out."
assert output["state"]["released"] == ["capture-timeout"]
assert output["state"]["completed"] == ["capture-timeout"]
assert output["state"]["keys"] == ["stable-operation", "stable-operation"]
assert output["second"]["confirmed"][0]["number"] == 9
def test_purge_cancels_stalled_delivery_without_reclaiming_it():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const items=[
{{id:'capture-purge-1',operationId:'capture-purge-1',ownerLogin:'timmy',repository:'o/r',title:'Private',body:'Draft',labelIds:[]}},
{{id:'capture-purge-2',operationId:'capture-purge-2',ownerLogin:'timmy',repository:'o/r',title:'Private 2',body:'Draft',labelIds:[]}},
];
const state={{released:[],closed:0,mutations:0}};
let mutationStarted;
const started=new Promise(resolve => mutationStarted=resolve);
const store={{
claimBatch:async()=>items.map(item=>({{...item}})),
complete:async()=>{{}},
release:async id=>state.released.push(id),
fail:async()=>{{}}, countBlocked:async()=>0, close:async()=>{{state.closed+=1;}},
}};
const fetchJson=async url=>{{
if(url==='api/v1/background-identity')return{{login:'timmy'}};
state.mutations+=1;mutationStarted();return new Promise(()=>{{}});
}};
(async()=>{{
const sync=createBackgroundIssueSync({{store,fetchJson,maxConcurrency:1,requestTimeoutMs:1000}});
sync.flush().catch(()=>{{}});
await started;
const outcome=await Promise.race([
sync.purge().then(()=> 'purged'),
new Promise(resolve=>setTimeout(()=>resolve('blocked'),80)),
]);
process.stdout.write(JSON.stringify({{state,outcome}}));
process.exit(0);
}})();
"""
output = run_node(script)
assert output["outcome"] == "purged"
assert output["state"] == {
"released": ["capture-purge-1", "capture-purge-2"],
"closed": 1,
"mutations": 1,
}
def test_bounded_flush_delivers_healthy_records_after_transient_failure_without_same_run_retry():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const items = [
{{id:'issue-1',operationId:'issue-1',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'One',body:'',labelIds:[]}},
{{id:'message-1',operationId:'message-1',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:1,body:'Reply'}},
{{id:'issue-bad',operationId:'issue-bad',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Bad',body:'',labelIds:[]}},
{{id:'message-2',operationId:'message-2',ownerLogin:'timmy',outboxLane:'authored',kind:'issue-comment',repository:'o/r',number:2,body:'Reply'}},
{{id:'issue-2',operationId:'issue-2',ownerLogin:'timmy',outboxLane:'issue',repository:'o/r',title:'Two',body:'',labelIds:[]}},
];
const state = {{active:0,maxActive:0,attempted:[],completed:[],released:[],batches:0}};
const store = {{
claimBatch: async (owner, limit) => items.slice(0, limit),
complete: async id => state.completed.push(id),
release: async id => state.released.push(id),
fail: async () => {{}}, countBlocked: async () => 0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const key = options.headers['Idempotency-Key'];
state.attempted.push(key); state.active += 1;
state.maxActive = Math.max(state.maxActive, state.active);
await new Promise(resolve => setTimeout(resolve, key === 'issue-bad' ? 5 : 20));
state.active -= 1;
if (key === 'issue-bad') {{ const error=new Error('Temporary outage'); error.status=503; throw error; }}
return {{id:key,number:7}};
}};
(async () => {{
let error;
try {{ await createBackgroundIssueSync({{
store,fetchJson,maxConcurrency:3,batchSize:10,
batch: async work => {{ state.batches += 1; return work(); }},
}}).flush(); }}
catch (caught) {{ error=caught.message; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output["state"]["maxActive"] == 3
assert output["state"]["batches"] == 1
assert sorted(output["state"]["attempted"]) == [
"issue-1", "issue-2", "issue-bad", "message-1", "message-2"
]
assert len(output["state"]["attempted"]) == len(set(output["state"]["attempted"]))
assert sorted(output["state"]["completed"]) == [
"issue-1", "issue-2", "message-1", "message-2"
]
assert output["state"]["released"] == ["issue-bad"]
assert output["error"] == "Temporary outage"
def test_bounded_flush_stops_admission_and_releases_unstarted_claims_on_auth_loss():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const items = Array.from({{length:5}}, (_, index) => ({{
id:'item-'+index,operationId:'item-'+index,ownerLogin:'timmy',outboxLane:'issue',
repository:'o/r',title:'Item',body:'',labelIds:[],
}}));
const state = {{attempted:[],released:[],completed:[]}};
const store = {{
claimBatch: async () => items,
complete: async id => state.completed.push(id),
release: async id => state.released.push(id), fail:async()=>{{}}, countBlocked:async()=>0,
}};
const fetchJson = async (url, options={{}}) => {{
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
const key=options.headers['Idempotency-Key']; state.attempted.push(key);
if (key === 'item-0') {{ const error=new Error('Authentication required'); error.status=401; throw error; }}
await new Promise(resolve => setTimeout(resolve, 20));
return {{number:1}};
}};
(async()=>{{
let error;
try {{ await createBackgroundIssueSync({{store,fetchJson,maxConcurrency:2}}).flush(); }}
catch (caught) {{ error={{message:caught.message,status:caught.status}}; }}
process.stdout.write(JSON.stringify({{state,error}}));
}})();
"""
output = run_node(script)
assert output["state"]["attempted"] == ["item-0", "item-1"]
assert sorted(output["state"]["released"]) == ["item-0", "item-2", "item-3", "item-4"]
assert output["state"]["completed"] == ["item-1"]
assert output["error"] == {"message": "Authentication required", "status": 401}
def test_issue_store_claims_one_finite_batch_fairly_across_outbox_lanes():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
const records=new Map();let tail=Promise.resolve();
const transaction=work=>{{const run=tail.then(()=>work({{
getAll:async()=>[...records.values()].map(value=>({{...value}})),
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
}}));tail=run.catch(()=>{{}});return run;}};
(async()=>{{
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100,claimMs:5000}});
await store.reconcile([
{{id:'issue-1',ownerLogin:'timmy',status:'queued'}},
{{id:'issue-2',ownerLogin:'timmy',status:'queued'}},
{{id:'issue-3',ownerLogin:'timmy',status:'queued'}},
], 'issue');
await store.reconcile([
{{id:'message-1',ownerLogin:'timmy',status:'queued'}},
{{id:'message-2',ownerLogin:'timmy',status:'queued'}},
], 'authored');
const claimed=await store.claimBatch('timmy', 4);
process.stdout.write(JSON.stringify({{claimed,snapshot:await store.snapshot()}}));
}})();
"""
output = run_node(script)
assert [item["id"] for item in output["claimed"]] == [
"issue-1", "message-1", "issue-2", "message-2"
]
assert all(item["status"] == "sending" for item in output["claimed"])
assert all(item["claimUntil"] == 5100 for item in output["claimed"])
assert next(item for item in output["snapshot"] if item["id"] == "issue-3")["status"] == "queued"
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
script = f"""
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
+6 -4
View File
@@ -4,6 +4,8 @@ from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import urljoin
from tests.dashboard_bundle import dashboard_bundle_text
FRONTEND = Path(__file__).parents[1] / "frontend"
COMMANDS = FRONTEND / "commands.js"
@@ -41,7 +43,7 @@ if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
def test_command_script_resolves_inside_dashboard_subpath():
parser = ScriptSourceParser()
parser.feed((FRONTEND / "index.html").read_text())
parser.feed(dashboard_bundle_text())
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
assert urljoin(
@@ -152,7 +154,7 @@ if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys sho
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
html = (FRONTEND / "index.html").read_text()
html = dashboard_bundle_text()
assert 'role="combobox"' in html
assert 'aria-controls="cmd-results"' in html
@@ -221,7 +223,7 @@ if (!states.some(state => state.status === 'claimed')) throw new Error('claim co
def test_remote_search_selection_opens_native_preview_without_navigation():
html = (FRONTEND / "index.html").read_text()
html = dashboard_bundle_text()
run_item = html.split("function runCommandItem(item)", 1)[1].split(
"function renderCommands", 1
@@ -233,7 +235,7 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
html = (FRONTEND / "index.html").read_text()
html = dashboard_bundle_text()
assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
+161
View File
@@ -63,3 +63,164 @@ const poller = createContextPoller({{
"callsWhileHidden": 1,
"callsAfterResume": 2,
}
def test_context_poller_sends_revisions_and_merges_changed_sections():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const requested = [];
const rendered = [];
const responses = [
{{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [],
sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }},
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}],
freshness: {{ age_seconds: 1 }} }},
{{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }},
revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }},
];
const poller = createContextPoller({{
fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }},
onSnapshot: (snapshot, changed) => rendered.push({{
context: snapshot.context.user.login,
event: snapshot.events[0].id,
age: snapshot.freshness.age_seconds,
changed,
}}),
onError: error => {{ throw error; }},
setTimer: () => 1,
clearTimer: () => {{}},
}});
(async () => {{
await poller.refresh();
await poller.refresh();
await poller.refresh();
process.stdout.write(JSON.stringify({{ requested, rendered }}));
}})();
"""
assert run_node(script) == {
"requested": [
{},
{"context": 1, "events": 1, "notifications": 1},
{"context": 1, "events": 2, "notifications": 1},
],
"rendered": [
{"context": "timmy", "event": 1, "age": 0,
"changed": ["context", "events", "notifications"]},
{"context": "timmy", "event": 2, "age": 1,
"changed": ["events"]},
{"context": "timmy", "event": 2, "age": 2, "changed": []},
],
}
def test_context_poller_aborts_a_stalled_request_and_recovers_on_schedule():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const timers = [];
const deadlineTimers = [];
const errors = [];
const snapshots = [];
const signals = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: (revisions, options) => {{
calls += 1;
signals.push(options.signal);
if (calls === 1) return new Promise(() => {{}});
return Promise.resolve({{ context: {{ user: {{ login: 'timmy' }} }} }});
}},
onSnapshot: snapshot => snapshots.push(snapshot.context.user.login),
onError: error => errors.push({{ name: error.name, message: error.message }}),
setTimer: (callback, delay) => {{
const timer = {{ callback, delay, cancelled: false }};
timers.push(timer);
return timer;
}},
clearTimer: timer => {{ timer.cancelled = true; }},
setDeadlineTimer: (callback, delay) => {{
const timer = {{ callback, delay, cancelled: false }};
deadlineTimers.push(timer);
return timer;
}},
clearDeadlineTimer: timer => {{ timer.cancelled = true; }},
intervalMs: 8000,
timeoutMs: 25,
}});
(async () => {{
const first = poller.start();
const deadline = deadlineTimers.find(timer => timer.delay === 25 && !timer.cancelled);
deadline.callback();
await first;
const retry = timers.find(timer => timer.delay === 8000 && !timer.cancelled);
retry.callback();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{
calls,
firstAborted: signals[0].aborted,
errors,
snapshots,
}}));
}})();
"""
assert run_node(script) == {
"calls": 2,
"firstAborted": True,
"errors": [
{"name": "TimeoutError", "message": "Live update timed out after 25ms."}
],
"snapshots": ["timmy"],
}
def test_forced_refresh_retires_stale_generation_without_late_overwrite():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
let resolveFirst;
let firstSignal;
const rendered = [];
const errors = [];
let calls = 0;
const poller = createContextPoller({{
fetchContext: (revisions, options) => {{
calls += 1;
if (calls === 1) {{
firstSignal = options.signal;
return new Promise(resolve => {{ resolveFirst = resolve; }});
}}
return Promise.resolve({{ context: {{ user: {{ login: 'fresh' }} }} }});
}},
onSnapshot: snapshot => rendered.push(snapshot.context.user.login),
onError: error => errors.push(error.name),
setTimer: () => 1,
clearTimer: () => {{}},
setDeadlineTimer: () => 1,
clearDeadlineTimer: () => {{}},
}});
(async () => {{
const stale = poller.start();
const fresh = poller.refresh({{ force: true }});
await fresh;
resolveFirst({{ context: {{ user: {{ login: 'stale' }} }} }});
await stale;
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{
calls,
firstAborted: firstSignal.aborted,
rendered,
errors,
}}));
}})();
"""
assert run_node(script) == {
"calls": 2,
"firstAborted": True,
"rendered": ["fresh"],
"errors": [],
}
+289
View File
@@ -1,5 +1,6 @@
import asyncio
import time
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
@@ -100,6 +101,43 @@ async def test_anonymous_private_request_is_rejected_before_gitea(access_control
assert called is False
@pytest.mark.anyio
async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fields(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get(
"/",
params={
"title": "Production crash",
"text": "Steps from the mobile app",
"url": "https://example.com/incidents/42",
"next": "https://evil.example/steal",
},
)
assert response.status_code == 303
login_query = parse_qs(urlsplit(response.headers["location"]).query)
assert login_query == {
"continue": [
"./?title=Production+crash&text=Steps+from+the+mobile+app&url="
"https%3A%2F%2Fexample.com%2Fincidents%2F42"
]
}
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_oversized_share_target_is_not_carried_through_login(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get(
"/", params={"title": "x" * 201, "text": "keep me"}
)
assert response.status_code == 303
assert response.headers["location"] == "login"
@pytest.mark.anyio
async def test_sign_in_creates_secure_session_without_echoing_access_token(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -276,6 +314,92 @@ async def test_logout_clears_session_and_blocks_private_routes(access_control):
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_sign_out_all_devices_revokes_every_existing_session(access_control):
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
await laptop.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await phone.delete(
"/api/v1/sessions",
headers={
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
},
)
phone_private = await phone.get("/api/v1/background-identity")
laptop_private = await laptop.get("/api/v1/background-identity")
assert response.status_code == 200
assert response.json() == {
"authenticated": False,
"all_sessions_revoked": True,
"clear_private_device_data": True,
}
assert phone_private.status_code == 401
assert laptop_private.status_code == 401
assert all("Max-Age=0" in value for value in response.headers.get_list("set-cookie"))
@pytest.mark.anyio
async def test_sign_out_all_devices_rejects_cross_site_requests_without_revoking(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
response = await client.delete(
"/api/v1/sessions",
headers={
"Origin": "https://evil.example",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
session = await client.get("/api/v1/session")
assert response.status_code == 403
assert response.headers.get_list("set-cookie") == []
assert session.status_code == 200
assert session.json()["authenticated"] is True
@pytest.mark.anyio
async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_control, monkeypatch):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
class BrokenStore:
def is_active(self, session_id, expires_at):
return True
def revoke_all(self):
raise SessionStoreError("database path and secret details")
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore())
response = await client.delete(
"/api/v1/sessions",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
assert response.status_code == 503
assert response.json() == {"detail": "Session registry is temporarily unavailable"}
assert response.headers["cache-control"] == "no-store"
assert response.headers.get_list("set-cookie") == []
assert "database path" not in response.text
@pytest.mark.anyio
async def test_logout_revokes_a_captured_cookie_before_gitea(access_control, monkeypatch):
calls = 0
@@ -344,6 +468,171 @@ async def test_session_registry_latency_does_not_block_the_event_loop(access_con
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
access_control, monkeypatch
):
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.15)
return 17
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post("/api/v1/session", json={"access_token": "wrong"})
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.01)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 429
assert response.headers["retry-after"] == "17"
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
access_control, monkeypatch
):
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def record_failure(self, source):
time.sleep(0.15)
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post("/api/v1/session", json={"access_token": "wrong"})
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 401
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
access_control, monkeypatch
):
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def clear(self, source):
time.sleep(0.15)
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 200
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_session_activation_does_not_block_the_event_loop(
access_control, monkeypatch
):
class Attempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def clear(self, source):
return None
class SlowStore:
def activate(self, session_id, expires_at):
time.sleep(0.15)
monkeypatch.setattr(main, "_login_attempt_store", lambda: Attempts())
monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: SlowStore())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.13", 1234))
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
sign_in = asyncio.create_task(
client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_in
assert response.status_code == 200
assert any(
"stackchain_session=" in value
for value in response.headers.get_list("set-cookie")
)
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_single_session_revocation_does_not_block_the_event_loop(
access_control, monkeypatch
):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple"},
)
csrf = client.cookies["stackchain_csrf"]
class SlowStore:
def is_active(self, session_id, expires_at):
time.sleep(0.02)
return True
def revoke(self, session_id):
time.sleep(0.15)
monkeypatch.setattr(
main.dashboard_auth, "_session_store", lambda now=None: SlowStore()
)
sign_out = asyncio.create_task(
client.delete(
"/api/v1/session",
headers={"Origin": "https://test", "X-CSRF-Token": csrf},
)
)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
response = await sign_out
assert response.status_code == 200
assert response.json()["authenticated"] is False
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_session_registry_read_failure_fails_closed_before_gitea(
access_control, monkeypatch
+94 -6
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
@@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletedCaches: [], assigned: '', workerMessages: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200 }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
@@ -27,14 +27,32 @@ const boundary = createSessionBoundary({{
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }});
return new Response('{{}}', {{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }});
return new Response('{{}}', {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }});
}},
localStorage: storage,
sessionStorage: storage,
indexedDB: {{ deleteDatabase: name => {{ state.deletedDatabases.push(name); return {{ onsuccess: null, onerror: null, onblocked: null }}; }} }},
indexedDB: {{ deleteDatabase: name => {{
state.deletedDatabases.push(name);
const request = {{ onsuccess: null, onerror: null, onblocked: null, error: null }};
setTimeout(() => {{
if (state.failDeletion) {{ request.error = new Error('database blocked'); request.onerror?.(); }}
else {{ state.deletionCompleted = true; request.onsuccess?.(); }}
}}, 20);
return request;
}} }},
caches: {{ keys: async () => ['stackchain-dashboard-shell-v15', 'gitea-assets'], delete: async key => {{ state.deletedCaches.push(key); }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: message => state.workerMessages.push(message) }} }}) }},
location: {{ assign: value => {{ state.assigned = value; }} }},
serviceWorker: {{ ready: Promise.resolve({{ active: {{ postMessage: (message, ports = []) => {{ state.workerMessages.push(message); ports[0]?.postMessage({{ok:true}}); }} }} }}) }},
MessageChannel: class {{ constructor() {{
const first = {{ onmessage: null, postMessage: data => queueMicrotask(() => second.onmessage?.({{data}})) }};
const second = {{ onmessage: null, postMessage: data => queueMicrotask(() => first.onmessage?.({{data}})) }};
this.port1 = first; this.port2 = second;
}} }},
location: {{
assign: value => {{ state.assigned = value; state.assignedAfterDeletion = state.deletionCompleted; }},
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
confirmAction: message => {{ state.confirmations.push(message); return true; }},
}});
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
@@ -53,6 +71,39 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
result = run_session_scenario(
"""
state.responseStatus = 401;
await Promise.all([
boundary.fetch('/dashboard/api/v1/live'),
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
state.responseStatus = 401;
await boundary.fetch('https://untrusted.example/api/private');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["replaced"] == []
def test_sign_out_clears_only_dashboard_private_device_state():
result = run_session_scenario(
"""
@@ -71,6 +122,41 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
assert result["assignedAfterDeletion"] is True
def test_sign_out_stays_on_page_and_reports_failed_private_outbox_deletion():
result = run_session_scenario(
"""
state.failDeletion = true;
try { await boundary.signOut(); } catch (error) { state.signOutError = error.message; }
process.stdout.write(JSON.stringify(state));
"""
)
assert result["assigned"] == ""
assert result["clearErrors"] == ["Could not clear private queued work from this device."]
assert result["signOutError"] == "Could not clear private queued work from this device."
def test_sign_out_all_devices_requires_confirmation_and_uses_global_endpoint():
result = run_session_scenario(
"""
await boundary.signOutAllDevices();
state.remaining = Array.from(storage.values.keys());
process.stdout.write(JSON.stringify(state));
"""
)
assert result["confirmations"] == [
"Sign out every device? You will need to sign in again everywhere."
]
request = result["requests"][0]
assert request["url"] == "/dashboard/api/v1/sessions"
assert request["method"] == "DELETE"
assert request["headers"]["x-csrf-token"] == "csrf-proof"
assert result["remaining"] == ["gitea.preference"]
assert result["assigned"] == "/dashboard/login"
def test_authenticated_dashboard_load_requests_queued_delivery_resume():
@@ -91,3 +177,5 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert '<script src="static/session.js"></script>' in html
assert html.index('static/session.js') < html.index('static/markdown.js')
assert '<button id="sign-out" type="button">Sign out &amp; clear this device</button>' in html
assert '<button id="sign-out-all" type="button">Sign out all devices</button>' in html
assert '#sign-out-all { min-height:44px; }' in html
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
DRAFTS = Path(__file__).parents[1] / "frontend" / "drafts.js"
+4 -3
View File
@@ -6,13 +6,14 @@ import pytest
from src import main
from src import gitea_proxy
from tests.dashboard_bundle import dashboard_bundle_text
DASHBOARD = Path("frontend/index.html")
def test_dashboard_has_realtime_gitea_event_stream_widget():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert "Gitea event stream" in html
assert "id=\"gitea-events\"" in html
@@ -25,14 +26,14 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
def test_event_stream_reports_when_activity_was_refreshed():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert 'id="gitea-events-status"' in html
assert "setEventStreamStatus('Updated ' + fmt(new Date()))" in html
def test_event_stream_reports_refresh_failure_without_erasing_visible_events():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert "setEventStreamStatus('Update failed · showing last activity')" in html
assert "qs('#gitea-events').innerHTML = '<div class=\"muted\">Event stream unavailable.</div>'" not in html
+212
View File
@@ -0,0 +1,212 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
INSTALL_APP = Path(__file__).resolve().parents[1] / "frontend" / "install-app.js"
def run_install_scenario(scenario: str) -> dict:
script = f"""
const createInstallApp = require({json.dumps(str(INSTALL_APP))});
class FakeTarget {{
constructor() {{ this.listeners = {{}}; }}
addEventListener(name, callback) {{ (this.listeners[name] ||= []).push(callback); }}
dispatch(name, event = {{}}) {{ for (const callback of this.listeners[name] || []) callback(event); }}
}}
class FakeButton extends FakeTarget {{
constructor() {{ super(); this.disabled = false; }}
click() {{ this.dispatch('click', {{currentTarget:this}}); }}
}}
(async () => {{
{scenario}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_native_install_prompt_is_revealed_by_eligibility_and_invoked_once():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const install = new FakeButton();
const dismiss = new FakeButton();
const guidance = {hidden:true};
const status = {textContent:''};
let promptCalls = 0;
const app = createInstallApp({
window:windowTarget, card, installButton:install, dismissButton:dismiss,
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {
preventDefault(){},
prompt:async () => { promptCalls += 1; },
userChoice:Promise.resolve({outcome:'accepted'}),
});
const shown = !card.hidden;
install.click();
await new Promise(resolve => setImmediate(resolve));
install.click();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({shown, promptCalls, hidden:card.hidden, status:status.textContent}));
"""
)
assert result == {
"shown": True,
"promptCalls": 1,
"hidden": True,
"status": "Stackchain was added to your device.",
}
def test_declined_native_install_hides_the_spent_choice_and_announces_it():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const install = new FakeButton();
const status = {textContent:''};
const app = createInstallApp({
window:windowTarget, card, installButton:install, dismissButton:new FakeButton(),
guidance:{hidden:true}, status, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {
preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'dismissed'}),
});
install.click();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({hidden:card.hidden, disabled:install.disabled, status:status.textContent}));
"""
)
assert result == {
"hidden": True,
"disabled": False,
"status": "Installation was not completed.",
}
def test_ios_safari_gets_manual_add_to_home_screen_guidance():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const install = new FakeButton(); install.hidden = false;
const dismiss = new FakeButton();
const guidance = {hidden:true};
const status = {textContent:''};
const app = createInstallApp({
window:windowTarget, card, installButton:install, dismissButton:dismiss,
guidance, status, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>true,
});
app.start();
process.stdout.write(JSON.stringify({cardHidden:card.hidden, installHidden:install.hidden, guidanceHidden:guidance.hidden}));
"""
)
assert result == {
"cardHidden": False,
"installHidden": True,
"guidanceHidden": False,
}
def test_standalone_launch_never_promotes_installation():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const app = createInstallApp({
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>true, isIosSafari:()=>true,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
process.stdout.write(JSON.stringify({hidden:card.hidden}));
"""
)
assert result == {"hidden": True}
def test_dismissal_is_remembered_and_suppresses_later_promotion():
result = run_install_scenario(
"""
const values = new Map();
const storage = {getItem:key => values.get(key) || null, setItem:(key, value) => values.set(key, value)};
const firstWindow = new FakeTarget();
const firstCard = {hidden:true};
const firstDismiss = new FakeButton();
const first = createInstallApp({
window:firstWindow, card:firstCard, installButton:new FakeButton(), dismissButton:firstDismiss,
guidance:{hidden:true}, status:{textContent:''}, storage,
isStandalone:()=>false, isIosSafari:()=>true,
});
first.start();
firstDismiss.click();
const secondCard = {hidden:true};
const second = createInstallApp({
window:new FakeTarget(), card:secondCard, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage,
isStandalone:()=>false, isIosSafari:()=>true,
});
second.start();
process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:values.size, secondHidden:secondCard.hidden}));
"""
)
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
def test_appinstalled_hides_an_visible_native_promotion():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const card = {hidden:true};
const app = createInstallApp({
window:windowTarget, card, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
const shown = !card.hidden;
windowTarget.dispatch('appinstalled');
process.stdout.write(JSON.stringify({shown, hidden:card.hidden}));
"""
)
assert result == {"shown": True, "hidden": True}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_touch_safe_install_promotion():
html = await dashboard()
assert 'id="install-app-card"' in html
assert 'id="install-app"' in html
assert 'id="dismiss-install-app"' in html
assert 'id="install-app-guidance"' in html
assert 'Share, then choose <strong>Add to Home Screen</strong>' in html
assert '.install-app-card button { min-height:44px;' in html
assert '<script src="static/install-app.js"></script>' in html
assert 'createInstallApp({' in html
assert "window.matchMedia('(display-mode: standalone)').matches" in html
assert "navigator.standalone === true" in html
assert "isIosSafari" in html
+158
View File
@@ -955,6 +955,76 @@ async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_
assert result["available"] is False
@pytest.mark.anyio
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
async def handler(request):
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
return httpx.Response(200, json=[
{"login": "timmy", "full_name": "Timmy"},
{"login": "alex", "full_name": "Alexander"},
{"login": "casey", "full_name": ""},
{"login": ""},
{"full_name": "Missing login"},
"malformed",
])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert result == [
{"login": "alex", "name": "Alexander"},
{"login": "casey", "name": "casey"},
]
@pytest.mark.anyio
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/assignees"):
return httpx.Response(200, json=[
{"login": "alex", "full_name": "Alexander"},
{"login": "casey", "full_name": "Casey"},
])
if request.method == "GET":
return httpx.Response(200, json={
"number": 17, "state": "open", "pull_request": None,
"assignees": [{"login": "timmy"}, {"login": "casey"}],
})
assert request.content == b'{"assignees":["casey","alex"]}'
return httpx.Response(200, json={
"number": 17, "state": "open",
"assignees": [{"login": "casey"}, {"login": "alex"}],
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 17, "state": "open",
"assignees": ["casey", "alex"], "recipient": "alex",
}
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/17"),
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/assignees"),
("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
]
@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
calls = []
@@ -981,6 +1051,94 @@ async def test_release_issue_endpoint_invalidates_available_work_and_returns_con
assert main._available_issue_snapshot_created_at is None
@pytest.mark.anyio
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def candidates(repository):
calls.append(("candidates", repository))
return [{"login": "alex", "name": "Alexander"}]
async def handoff(repository, number, recipient):
calls.append(("handoff", repository, number, recipient))
return {
"repository": repository, "number": number, "state": "open",
"assignees": [recipient], "recipient": recipient,
}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get(
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
)
transferred = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/handoff",
json={"recipient": "alex"},
)
assert listed.status_code == 200
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
assert transferred.status_code == 200
assert transferred.json()["recipient"] == "alex"
assert listed.headers["cache-control"] == "no-store"
assert transferred.headers["cache-control"] == "no-store"
assert calls == [
("assigned", "stackchain/api", 17),
("candidates", "stackchain/api"),
("handoff", "stackchain/api", 17, "alex"),
]
@pytest.mark.anyio
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
async def handoff(_repository, _number, _recipient):
raise gitea_proxy.IssueNotAvailableError("stale")
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/handoff",
json={"recipient": "alex"},
)
assert response.status_code == 409
assert response.json() == {
"error": "The issue or recipient changed. Reload before handing off."
}
@pytest.mark.anyio
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
called = False
async def assigned(_repository, _number):
return False
async def candidates(_repository):
nonlocal called
called = True
return []
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
)
assert response.status_code == 404
assert called is False
@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
async def assigned(repository, number):
+38 -3
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
OUTBOX = Path(__file__).parents[1] / "frontend" / "issue-outbox.js"
@@ -110,9 +110,10 @@ const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
let invalid = true;
let operationSequence = 0;
const calls = [];
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'stable-edit',
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'edit-' + (++operationSequence),
fetchJson:async (_url, options) => {{
calls.push({{key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}});
if (invalid) {{ const error = new Error('Title is invalid'); error.status = 422; throw error; }}
@@ -132,12 +133,46 @@ outbox.flush('timmy').then(async () => {{
assert output["attention"]["status"] == "attention"
assert output["attention"]["error"] == "Title is invalid"
assert [call["key"] for call in output["calls"]] == ["stable-edit", "stable-edit"]
assert [call["key"] for call in output["calls"]] == ["edit-1", "edit-2"]
assert output["calls"][1]["body"]["title"] == "Fixed title"
assert output["result"]["confirmed"][0]["number"] == 42
assert output["remaining"] == []
def test_issue_outbox_rotates_operation_id_only_when_delivery_payload_changes():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=>'timmy', createOperationId:() => 'payload-' + (++sequence),
}});
const queued = outbox.enqueue({{
repository:'stackchain/api',title:'Plan',body:'Context',labelIds:[1],milestoneId:2,dueDate:'2026-08-09'
}});
const unchanged = outbox.update(queued.id, {{...queued}});
const fields = [
['repository', 'stackchain/web'], ['title', 'Revised'], ['body', 'More context'],
['labelIds', [1, 3]], ['milestoneId', 4], ['dueDate', '2026-08-10'],
];
const edits = [];
for (const [field, value] of fields) {{
const current = outbox.list()[0];
edits.push(outbox.update(queued.id, {{...current, [field]:value}}));
}}
process.stdout.write(JSON.stringify({{queued,unchanged,edits,final:outbox.list()[0]}}));
"""
output = run_node(script)
assert output["unchanged"]["operationId"] == output["queued"]["operationId"] == "payload-1"
assert [item["operationId"] for item in output["edits"]] == [
"payload-2", "payload-3", "payload-4", "payload-5", "payload-6", "payload-7"
]
assert output["final"]["milestoneId"] == 4
assert output["final"]["dueDate"] == "2026-08-10"
def test_issue_outbox_is_single_flight_when_reconnect_and_send_now_overlap():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
+43
View File
@@ -21,6 +21,9 @@ def reset_live_snapshot_task():
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
yield
main._live_snapshot_task = None
main._live_snapshot_value = None
@@ -35,6 +38,9 @@ def reset_live_snapshot_task():
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
}
main._live_snapshot_refreshing_sections = set()
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
def payload(response):
@@ -84,6 +90,43 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh"
}
assert result["revisions"] == {
"context": 1, "events": 1, "notifications": 1
}
@pytest.mark.anyio
async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch):
async def user():
return {"id": 1, "login": "timmy"}
async def empty():
return []
async def events(_authenticated_user):
return [{"type": "push"}]
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty)
monkeypatch.setattr(main, "issues", empty)
monkeypatch.setattr(main, "pull_requests", empty)
monkeypatch.setattr(main, "activity_events", events)
monkeypatch.setattr(main, "notifications", empty)
first = payload(await main.live_snapshot())
second = payload(await main.live_snapshot(
context_revision=first["revisions"]["context"],
events_revision=first["revisions"]["events"],
notifications_revision=first["revisions"]["notifications"],
))
assert "context" not in second
assert "events" not in second
assert "notifications" not in second
assert "notification_pagination" not in second
assert second["sections"] == first["sections"]
assert second["revisions"] == first["revisions"]
assert "freshness" in second
@pytest.mark.anyio
+143
View File
@@ -11,6 +11,32 @@ ROOT = Path(__file__).resolve().parents[1]
LOGIN_JS = ROOT / "frontend" / "login.js"
def test_expired_session_reason_explains_preserved_private_work():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }},
}});
controller.showReason('session-expired');
const expired = status.textContent;
controller.showReason('https://evil.example/redirect');
process.stdout.write(JSON.stringify({{ expired, ignored: status.textContent }}));
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
state = json.loads(result.stdout)
assert state["expired"] == (
"Your session expired. Private drafts remain on this device. "
"Sign in to continue."
)
assert state["ignored"] == state["expired"]
def test_rate_limited_login_disables_submit_and_counts_down():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
@@ -52,9 +78,126 @@ const controller = createLoginController({{
}
def test_successful_login_resumes_valid_shared_capture_continuation():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const state = {{ replaced: null }};
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => state.replaced = value }},
continuation: './?title=Production+crash&url=https%3A%2F%2Fexample.com',
}});
(async () => {{
await controller.submit('operator-token');
process.stdout.write(JSON.stringify(state));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout)["replaced"] == (
"./?title=Production+crash&url=https%3A%2F%2Fexample.com"
)
def test_shared_capture_login_explains_why_sign_in_is_required():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const status = {{ textContent: '' }};
createLoginController({{
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: () => {{}} }}, continuation: './?title=Shared',
}});
process.stdout.write(status.textContent);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert result.stdout == "Sign in to continue your shared capture."
def test_login_rejects_untrusted_or_non_share_continuations():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => {{
process.stdout.write(JSON.stringify({{
crossOrigin: await destination('https://evil.example/steal'),
otherRoute: await destination('./settings?title=Shared'),
extraField: await destination('./?title=Shared&next=https%3A%2F%2Fevil.example'),
prototypeField: await destination('./?toString=Shared'),
oversized: await destination('./?title=' + 'x'.repeat(201)),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"crossOrigin": "./",
"otherRoute": "./",
"extraField": "./",
"prototypeField": "./",
"oversized": "./",
}
def test_login_bootstrap_continues_shared_capture_after_submit():
harness = f"""
const fs = require('fs');
const vm = require('vm');
const state = {{ replaced: null, submit: null }};
const elements = {{
'sign-in': {{ reset: () => {{}}, addEventListener: (_name, handler) => state.submit = handler }},
'status': {{ textContent: '' }},
'submit-sign-in': {{ disabled: false }},
}};
const context = {{
URLSearchParams, Response, setInterval, clearInterval,
FormData: function () {{ return {{ get: () => 'operator-token' }}; }},
fetch: async () => new Response('{{}}', {{ status: 200 }}),
document: {{ getElementById: id => elements[id] }},
window: {{ location: {{
search: '?continue=.%2F%3Ftitle%3DShared%2Blink%26url%3Dhttps%253A%252F%252Fexample.com',
replace: value => state.replaced = value,
}} }},
}};
context.fetch.bind = Function.prototype.bind;
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(LOGIN_JS))}, 'utf8'), context);
state.submit({{ preventDefault: () => {{}} }});
setTimeout(() => process.stdout.write(JSON.stringify({{
replaced: state.replaced, status: elements.status.textContent,
}})), 0);
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"replaced": "./?title=Shared+link&url=https%3A%2F%2Fexample.com",
"status": "Signing in…",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
assert '<script src="static/login.js"></script>' in html
assert "main{box-sizing:border-box" in html
assert '<p id="status" role="status" aria-live="polite"></p>' in html
+72
View File
@@ -0,0 +1,72 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
script = f"""
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
process.stdout.write(JSON.stringify({{
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"today": "today",
"attention": "attention",
"all": "all",
"saved": "review",
}
def test_mobile_disclosure_closes_on_escape_and_restores_launcher_focus():
script = f"""
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
const listeners = {{}};
const disclosure = {{open:true, addEventListener(name, fn) {{ listeners[name] = fn; }}}};
const launcher = {{focuses:0, focus() {{ this.focuses += 1; }}}};
const controller = mobileLaunch.createDisclosure({{disclosure, launcher}});
controller.start();
let prevented = false;
listeners.keydown({{key:'Escape', preventDefault() {{ prevented = true; }}}});
process.stdout.write(JSON.stringify({{open:disclosure.open, focuses:launcher.focuses, prevented}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"open": False, "focuses": 1, "prevented": True}
@pytest.mark.anyio
async def test_mobile_launch_progressively_discloses_secondary_controls():
html = await dashboard()
assert 'class="app-brand"' in html
assert 'class="app-live-status"' in html
assert 'class="app-menu"' in html
assert 'id="app-menu-toggle"' in html
assert 'class="work-settings"' in html
assert 'id="work-settings-toggle"' in html
assert 'id="active-work-queue"' in html
assert '<script src="static/mobile-launch.js"></script>' in html
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
assert "BASE + 'static/mobile-launch.js'" in service_worker
assert "mobileLaunch.chooseFilter" in html
assert "mobileLaunch.createDisclosure" in html
assert "@media (max-width: 600px)" in html
assert "header { min-height:56px; max-height:64px;" in html
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
assert ".my-work-actions button { min-height:44px;" in html
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
+152 -8
View File
@@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
@@ -470,6 +470,58 @@ controller.release({{repository:'stackchain/api', number:17}}, 'timmy')
assert result.stdout == "Issue release was not confirmed."
def test_issue_handoff_is_single_flight_and_requires_confirmed_transfer():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let calls = [];
let finish;
const controller = createIssueSheet({{
fetchJson: (url, options) => {{
calls.push({{url, options}});
return new Promise(resolve => {{ finish = resolve; }});
}},
storage: null,
}});
const item = {{repository:'stackchain/api', number:17}};
const first = controller.handoff(item, 'alex', 'timmy');
const duplicate = controller.handoff(item, 'alex', 'timmy');
finish({{number:17, repository:'stackchain/api', recipient:'alex', assignees:['alex']}});
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, same: first === duplicate, results
}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["same"] is True
assert len(output["calls"]) == 1
assert output["calls"][0]["url"].endswith("/issues/17/handoff")
assert output["calls"][0]["options"]["method"] == "PATCH"
assert json.loads(output["calls"][0]["options"]["body"]) == {"recipient": "alex"}
assert output["results"][0]["assignees"] == ["alex"]
def test_issue_handoff_candidates_use_the_assigned_issue_route():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
let call;
const controller = createIssueSheet({{
fetchJson: async (url, options) => {{ call = {{url, options}}; return [{{login:'alex', name:'Alexander'}}]; }},
storage: null,
}});
controller.loadHandoffCandidates({{repository:'stackchain/api', number:17}})
.then(result => process.stdout.write(JSON.stringify({{call, result}})));
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["call"]["url"].endswith("/issues/17/handoff-candidates")
assert output["call"]["options"]["headers"]["Accept"] == "application/json"
assert output["result"] == [{"login": "alex", "name": "Alexander"}]
def test_issue_content_edit_is_single_flight_and_keeps_scoped_draft_until_confirmed():
script = f"""
const createIssueSheet = require({json.dumps(str(ISSUE_SHEET))});
@@ -576,6 +628,19 @@ async def test_mobile_issue_sheet_exposes_touch_sized_due_date_editor_and_card_b
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_issue_sheet_exposes_touch_safe_teammate_handoff():
html = await dashboard()
assert 'id="issue-handoff-recipient"' in html
assert 'id="load-issue-handoff"' in html
assert 'id="confirm-issue-handoff"' in html
assert 'id="issue-handoff-status" class="small" aria-live="assertive"' in html
assert '.issue-handoff select, .issue-handoff button { min-height:44px;' in html
assert "issueController.loadHandoffCandidates(selectedIssue)" in html
assert "issueController.handoff(selectedIssue, recipient" in html
@pytest.mark.anyio
async def test_new_issue_sheet_exposes_touch_safe_release_planning_controls():
html = await dashboard()
@@ -599,7 +664,7 @@ async def test_dashboard_launches_share_capture_with_draft_conflict_choices():
assert 'id="resume-issue-draft"' in html
assert 'id="shared-content-conflict"' in html
assert '.shared-content-actions button { min-height:44px;' in html
assert "history.replaceState({}, '', cleanUrl)" in html
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
assert "navigator.serviceWorker.register('service-worker.js')" in html
@@ -760,7 +825,7 @@ process.stdout.write(JSON.stringify({{
)
assert json.loads(result.stdout) == {
"deferred": True,
"deferred": "deferred",
"timmy": {
"active": 0,
"later": [{
@@ -773,6 +838,33 @@ process.stdout.write(JSON.stringify({{
}
def test_later_queue_reports_invalid_identity_and_failed_storage_without_false_success():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
let login = '';
const item = {{kind:'issue',repository:'stackchain/api',number:17}};
const unavailable = createLaterWork({{
storage: {{getItem:() => null,setItem:() => {{ throw new Error('quota'); }},removeItem:() => {{}}}},
getLogin:() => login, now:() => new Date('2026-08-08T12:00:00Z'),
}});
const noIdentity = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
login = 'timmy';
const invalid = unavailable.defer(item, new Date('2026-08-08T11:00:00Z'));
const failedWrite = unavailable.defer(item, new Date('2026-08-08T16:00:00Z'));
process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"noIdentity": "unavailable",
"invalid": "invalid",
"failedWrite": "unavailable",
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
@@ -899,7 +991,7 @@ let sessionActive = false;
const controller = createDetailDefer({{
laterWork: {{
presetUntil:preset => new Date(preset === 'today' ? '2026-08-08T16:00:00Z' : '2026-08-09T09:00:00Z'),
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return true; }},
defer:(item, until) => {{ calls.push(['defer', item.title, until.toISOString()]); return 'deferred'; }},
}},
session: {{ active:() => sessionActive }},
close:() => calls.push(['close']),
@@ -935,6 +1027,32 @@ process.stdout.write(JSON.stringify({{outside,session,calls}}));
}
def test_detail_defer_reports_storage_failure_without_closing_or_refreshing():
script = f"""
const createDetailDefer = require({json.dumps(str(DETAIL_DEFER))});
const calls = [];
const controller = createDetailDefer({{
laterWork: {{
presetUntil:() => new Date('2026-08-08T16:00:00Z'),
defer:() => 'unavailable',
}},
session: {{active:() => false}},
close:() => calls.push('close'), refresh:() => calls.push('refresh'),
focus:() => calls.push('focus'), announce:message => calls.push(message),
}});
const saved = controller.defer({{kind:'issue',repository:'stackchain/api',number:17}}, 'today');
process.stdout.write(JSON.stringify({{saved,calls}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"saved": False,
"calls": ["Could not save Later on this device."],
}
@pytest.mark.anyio
async def test_mobile_detail_sheets_offer_touch_safe_defer_without_a_gitea_mutation():
html = await dashboard()
@@ -958,16 +1076,16 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
assert 'data-work-filter="later"' in html
assert 'data-work-count="later"' in html
assert 'const laterWork = createLaterWork({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'getLogin: () => planningOwnerLogin' in html
assert 'laterWork.partition(lastMyWork,' in html
assert 'data-later-preset="today"' in html
assert 'data-later-preset="tomorrow"' in html
assert 'data-later-restore' in html
assert "Deferred until ' + escapeHtml(fmt(item.deferred_until))" in html
assert "laterWork.defer(item, laterWork.presetUntil(button.dataset.laterPreset))" in html
assert "const result = laterWork.defer(item, until)" in html
assert 'laterWork.restore(item)' in html
assert '.later-actions button { min-height:44px;' in html
assert 'Deferred work stays unread and unchanged in Gitea.' in html
assert "'Deferred until ' + fmt(until)" in html
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
@@ -2528,6 +2646,31 @@ Promise.all([first, duplicate]).then(async comments => {{
assert len(output["comments"]) == 2 and len(output["merges"]) == 2
def test_pull_sheet_surfaces_pending_merge_confirmation_guidance():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
const controller = createPullSheet({{
storage: null,
fetchJson: () => Promise.resolve({{
number: 7,
merged: false,
state: 'unknown',
confirmation_pending: true,
error: 'Merge confirmation is pending. Check its status before retrying.',
}}),
}});
controller.merge({{repository:'stackchain/api', number:7}}, 'abc123')
.then(() => process.stdout.write(JSON.stringify({{resolved:true}})))
.catch(error => process.stdout.write(JSON.stringify({{resolved:false, message:error.message}})));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(result.stdout) == {
"resolved": False,
"message": "Merge confirmation is pending. Check its status before retrying.",
}
def test_pull_sheet_enables_merge_only_for_current_safe_state():
script = f"""
const createPullSheet = require({json.dumps(str(PULL_SHEET))});
@@ -2964,13 +3107,14 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
assert 'data-work-count="all"' in html
assert 'data-work-count="today"' in html
assert 'data-work-count="attention"' in html
assert 'data-work-count="issue"' in html
assert 'data-work-count="pull"' in html
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert 'data-work-count="later"' in html
assert "['all', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+3 -2
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
@@ -112,4 +112,5 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
assert "function renderLiveSnapshot(snapshot, changedSections" in html
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).parents[1]
+4 -2
View File
@@ -1,11 +1,13 @@
from pathlib import Path
from tests.dashboard_bundle import dashboard_bundle_text
DASHBOARD = Path("frontend/index.html")
def test_dashboard_panels_are_independently_collapsible():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
for panel_key in ("context", "ai-stream", "issues", "pull-requests", "widgets", "layout", "markdown"):
assert f'<details class="panel' in html
@@ -13,7 +15,7 @@ def test_dashboard_panels_are_independently_collapsible():
def test_panel_open_state_is_restored_and_saved_in_local_storage():
html = DASHBOARD.read_text()
html = dashboard_bundle_text()
assert 'const PANEL_STATE_KEY = "stackchain.panel-state.v1"' in html
assert "localStorage.getItem(PANEL_STATE_KEY)" in html
+104
View File
@@ -1,3 +1,5 @@
import asyncio
import httpx
import pytest
@@ -247,6 +249,108 @@ async def test_assigned_pull_merge_requires_current_eligible_head(monkeypatch):
assert calls == [("stackchain/api", 7, "abc123")]
@pytest.mark.anyio
async def test_assigned_pull_merge_reconciles_acceptance_before_timeout(monkeypatch):
calls = []
merged = False
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
nonlocal merged
calls.append(("merge", repository, number, expected_head_sha))
merged = True
await asyncio.sleep(0.05)
return {"number": number, "merged": True, "state": "closed"}
async def confirm(repository, number, expected_head_sha):
calls.append(("confirm", repository, number, expected_head_sha))
return merged
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 200
assert response.json() == {"number": 7, "merged": True, "state": "closed"}
assert calls == [
("merge", "stackchain/api", 7, "abc123"),
("confirm", "stackchain/api", 7, "abc123"),
]
@pytest.mark.anyio
async def test_gitea_merge_confirmation_requires_merged_expected_head():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
return httpx.Response(200, json={
"number": 7,
"state": "closed",
"merged": True,
"head": {"sha": "abc123"},
})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
confirmed = await gitea_proxy.is_pull_merged_at_head(
"stackchain/api", 7, "abc123"
)
finally:
await gitea_proxy.stop_client()
assert confirmed is True
assert requests == [("GET", "/api/v1/repos/stackchain/api/pulls/7")]
@pytest.mark.anyio
async def test_assigned_pull_merge_reports_unresolved_confirmation_without_false_state(monkeypatch):
calls = []
async def assigned(repository, number):
return True
async def merge(repository, number, expected_head_sha):
calls.append(("merge", repository, number, expected_head_sha))
raise httpx.ReadTimeout("upstream response was lost")
async def confirm(repository, number, expected_head_sha):
calls.append(("confirm", repository, number, expected_head_sha))
return False
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge)
monkeypatch.setattr(main.gitea_proxy, "is_pull_merged_at_head", confirm)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/repos/stackchain/api/pulls/7/merge",
json={"expected_head_sha": "abc123"},
)
assert response.status_code == 202
assert response.json() == {
"number": 7,
"merged": False,
"state": "unknown",
"confirmation_pending": True,
"error": "Merge confirmation is pending. Check its status before retrying.",
}
assert calls == [
("merge", "stackchain/api", 7, "abc123"),
("confirm", "stackchain/api", 7, "abc123"),
]
@pytest.mark.anyio
async def test_assigned_pull_merge_returns_conflict_without_mutating_stale_head(monkeypatch):
async def assigned(repository, number):
+77 -5
View File
@@ -1,4 +1,5 @@
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
@@ -12,12 +13,34 @@ from src.release_engine import (
Issue,
ReleaseEngine,
RunState,
ShellRunner,
StateStore,
branch_name,
select_issue,
)
def test_shell_runner_treats_metacharacters_as_literal_arguments(tmp_path):
output = tmp_path / "argument.txt"
injected = tmp_path / "injected.txt"
payload = f"release title; touch {injected}"
result = ShellRunner().run(
[
sys.executable,
"-c",
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])",
str(output),
payload,
],
tmp_path,
)
assert result.returncode == 0
assert output.read_text() == payload
assert not injected.exists()
def test_stale_token_error_explains_how_to_restore_cron_authentication():
class UnauthorizedHandler(BaseHTTPRequestHandler):
def do_GET(self):
@@ -70,9 +93,11 @@ class FakeRunner:
def __init__(self, results):
self.results = list(results)
self.commands = []
self.environments = []
def run(self, command, cwd, env=None):
self.commands.append(command)
self.environments.append(env)
return self.results.pop(0)
@@ -91,7 +116,7 @@ def config(tmp_path):
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command="agent --issue {issue_number}",
agent_command="agent --issue-from-env",
test_command="pytest -q",
)
@@ -121,6 +146,24 @@ def test_claim_verification_blocks_execution(tmp_path):
assert runner.commands == []
def test_empty_agent_command_fails_before_claim(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
cfg = EngineConfig(
repo="stackchain/stackchain-dashboard",
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command=" ",
test_command="pytest -q",
)
with pytest.raises(ValueError, match="invalid agent command: command is empty"):
ReleaseEngine(cfg, client, FakeRunner([])).execute(issue_number=14)
assert client.claimed == []
def test_agent_failure_blocks_tests_and_pr(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
@@ -134,12 +177,41 @@ def test_agent_failure_blocks_tests_and_pr(tmp_path):
engine.execute(issue_number=14)
assert runner.commands == [
"git checkout -B timmy/14-engine",
"agent --issue 14",
["git", "checkout", "-B", "timmy/14-engine"],
["agent", "--issue-from-env"],
]
assert client.created_prs == []
def test_issue_content_reaches_agent_only_through_environment(tmp_path):
title = "$(touch /tmp/title-injected); `id`"
body = "line one\n&& touch /tmp/body-injected > stolen"
issue = Issue(14, title, body, "open", [], None)
client = FakeClient([issue])
runner = FakeRunner([
CommandResult(0, "", ""),
CommandResult(1, "", "agent stopped"),
])
cfg = EngineConfig(
repo="stackchain/stackchain-dashboard",
agent="timmy",
repo_path=tmp_path,
state_path=tmp_path / "state.json",
agent_command="agent --work",
test_command="pytest -q",
)
with pytest.raises(RuntimeError, match="coding agent failed"):
ReleaseEngine(cfg, client, runner).execute(issue_number=14)
assert runner.commands == [
["git", "checkout", "-B", "timmy/14-touch-tmp-title-injected-id"],
["agent", "--work"],
]
assert runner.environments[1]["RELEASE_ISSUE_TITLE"] == title
assert runner.environments[1]["RELEASE_ISSUE_BODY"] == body
def test_failed_tests_block_push_and_pr(tmp_path):
issue = Issue(14, "Engine", "body", "open", [], None)
client = FakeClient([issue])
@@ -153,7 +225,7 @@ def test_failed_tests_block_push_and_pr(tmp_path):
with pytest.raises(RuntimeError, match="tests failed"):
engine.execute(issue_number=14)
assert "git push" not in " ".join(runner.commands)
assert not any(command[:2] == ["git", "push"] for command in runner.commands)
assert client.created_prs == []
@@ -171,7 +243,7 @@ def test_passing_run_pushes_and_opens_linked_pr(tmp_path):
result = engine.execute(issue_number=14)
assert result["pr_number"] == 44
assert runner.commands[-1].startswith("git push -u origin")
assert runner.commands[-1] == ["git", "push", "-u", "origin", "timmy/14-engine-mvp"]
assert len(client.created_prs) == 1
body = client.created_prs[0][4]
assert "Closes #14" in body
+59
View File
@@ -0,0 +1,59 @@
import httpx
import pytest
from src import main
REQUIRED_HEADERS = {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"x-frame-options": "DENY",
}
def assert_browser_security_boundary(response: httpx.Response) -> None:
for name, value in REQUIRED_HEADERS.items():
assert response.headers[name] == value
assert "camera=()" in response.headers["permissions-policy"]
policy = response.headers["content-security-policy"]
assert "default-src 'self'" in policy
assert "script-src 'self'" in policy
assert "object-src 'none'" in policy
assert "frame-ancestors 'none'" in policy
script_policy = next(part for part in policy.split(";") if "script-src" in part)
assert "'unsafe-inline'" not in script_policy
assert "'unsafe-eval'" not in script_policy
@pytest.mark.anyio
async def test_security_boundary_covers_pages_health_static_and_api_errors(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
responses = [
await client.get("/healthz"),
await client.get("/login"),
await client.get("/static/dashboard.js"),
await client.get("/api/v1/context"),
]
assert [response.status_code for response in responses] == [200, 200, 200, 401]
for response in responses:
assert_browser_security_boundary(response)
@pytest.mark.anyio
async def test_security_boundary_covers_fail_closed_authentication_response(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/login")
assert response.status_code == 503
assert_browser_security_boundary(response)
+88 -9
View File
@@ -13,14 +13,14 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
}};
const context = {{
URL, Request, Response,
URL, Request, Response, Headers, AbortController,
console,
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
@@ -34,6 +34,7 @@ const context = {{
registration: {{showNotification: async (title, options) => state.notifications.push({{title,options}})}},
__issueSync: {{
flush: async () => {{ state.backgroundFlushes += 1; return state.flushResult; }},
purge: async () => {{ state.outboxPurges += 1; }},
getReceiptPreference: async login => state.receiptLogin === login,
}},
}},
@@ -47,11 +48,13 @@ const context = {{
fetch: async request => {{
state.fetches.push(String(request.url || request));
if (state.failFetch) throw new Error('offline');
return new Response('network', {{ status: state.fetchStatus }});
const response = new Response('network', {{ status: state.fetchStatus }});
Object.defineProperty(response, 'redirected', {{ value: state.fetchRedirected }});
return response;
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8'), context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
async function dispatch(name, request) {{
let pending;
let response;
@@ -68,9 +71,9 @@ async function dispatchSync(tag) {{
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchMessage(data) {{
async function dispatchMessage(data, ports = []) {{
let pending;
listeners.message({{ data, waitUntil: promise => {{ pending = promise; }} }});
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
@@ -91,10 +94,13 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_deadline_attention_flow_ships_in_a_new_shell_cache():
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v19" in source
assert "stackchain-dashboard-shell-v32" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
@@ -120,6 +126,44 @@ def test_authenticated_page_message_resumes_queued_background_delivery():
assert result["backgroundFlushes"] == 1
def test_background_mutation_abort_also_cancels_stalled_csrf_lookup():
result = run_worker_scenario(
"""
let mutations=0;
context.fetch=(request, options={})=>{
if(String(request).includes('/api/v1/session')){
return new Promise((resolve,reject)=>options.signal?.addEventListener('abort',()=>reject(new Error('aborted')), {once:true}));
}
mutations+=1;
return Promise.resolve(new Response('{}',{status:200,headers:{'Content-Type':'application/json'}}));
};
const controller=new AbortController();
const request=context.self.__testWithSessionCsrf(() =>
context.self.__testFetchJson('/dashboard/api/v1/repos/o/r/issues',{method:'POST',signal:controller.signal})
).then(()=> 'completed', error=>error.message);
controller.abort();
const outcome=await Promise.race([request,new Promise(resolve=>setTimeout(()=>resolve('blocked'),40))]);
process.stdout.write(JSON.stringify({outcome,mutations}));
process.exit(0);
"""
)
assert result == {"outcome": "aborted", "mutations": 0}
def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
result = run_worker_scenario(
"""
const replies = [];
await dispatchMessage({type:'stackchain-purge-outbox'}, [{postMessage: value => replies.push(value)}]);
process.stdout.write(JSON.stringify({state,replies}));
"""
)
assert result["state"]["outboxPurges"] == 1
assert result["replies"] == [{"ok": True}]
def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_route():
result = run_worker_scenario(
"""
@@ -163,11 +207,24 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()
assert "async function sessionCsrf()" in source
assert "async function sessionCsrf(signal)" in source
assert "BASE + 'api/v1/session'" in source
assert "signal," in source
assert "headers.set('X-CSRF-Token', csrf)" in source
def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
source = WORKER.read_text()
assert "async function withSessionCsrf(work)" in source
assert "batchedCsrfController = new AbortController()" in source
assert "batchedCsrf = sessionCsrf(batchedCsrfController.signal)" in source
assert "batchedCsrfController.abort()" in source
assert "await (batchedCsrf || sessionCsrf(options.signal))" in source
assert "if (options.signal?.aborted)" in source
assert "batch: withSessionCsrf" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
@@ -181,6 +238,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
assert set(result["added"]) == {
"/dashboard/",
"/dashboard/manifest.webmanifest",
"/dashboard/static/dashboard.css",
"/dashboard/static/dashboard.js",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
@@ -189,11 +248,13 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/search-preview.js",
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
"/dashboard/static/offline-work.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
"/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/pick-work.js",
@@ -205,6 +266,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/work-route.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/background-issue-sync.js",
}
@@ -240,6 +303,22 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_redirected_login_navigation_does_not_replace_cached_dashboard_shell():
result = run_worker_scenario(
"""
state.fetchRedirected = true;
const response = await dispatch('fetch', {
method: 'GET', mode: 'navigate',
url: 'https://forge.example/dashboard/?title=Shared',
});
process.stdout.write(JSON.stringify({ status: response.status, state }));
"""
)
assert result["status"] == 200
assert result["state"]["puts"] == []
def test_cross_origin_navigation_is_not_intercepted_or_cached():
result = run_worker_scenario(
"""
+16
View File
@@ -22,6 +22,22 @@ def test_revocation_is_durable_and_scoped_to_one_session(tmp_path):
assert b"second-session-secret" not in database.read_bytes()
def test_revoke_all_is_durable_and_does_not_block_future_sessions(tmp_path):
now = [1_000.0]
database = tmp_path / "sessions.sqlite3"
first = SessionStore(database, clock=lambda: now[0])
first.activate("phone-session", 2_000)
first.activate("laptop-session", 2_000)
first.revoke_all()
reconstructed = SessionStore(database, clock=lambda: now[0])
assert reconstructed.is_active("phone-session", 2_000) is False
assert reconstructed.is_active("laptop-session", 2_000) is False
reconstructed.activate("new-session", 2_000)
assert reconstructed.is_active("new-session", 2_000) is True
def test_validation_does_not_create_a_missing_registry(tmp_path):
database = tmp_path / "sessions.sqlite3"
store = SessionStore(database, clock=lambda: 1_000.0)
+22 -2
View File
@@ -1,6 +1,6 @@
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
@pytest.mark.anyio
@@ -22,7 +22,9 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
html = await dashboard()
assert "fetch('api/v1/live'" in html
assert "renderLiveSnapshot(snapshot)" in html
assert "onSnapshot: renderLiveSnapshot" in html
assert "section + '_revision'" in html
assert "workChanged = contextChanged || notificationsChanged" in html
assert "renderContextSnapshot(snapshot.context)" in html
assert "paintEventStream(snapshot.events)" in html
assert "loadEventStream" not in html
@@ -55,3 +57,21 @@ async def test_dashboard_reports_each_live_section_from_its_own_freshness():
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
assert "Unread updates unavailable · showing last known updates" in html
assert "Activity refresh failed · showing last activity" in html
@pytest.mark.anyio
async def test_operator_refresh_and_reconnect_supersede_a_stalled_live_request():
html = await dashboard()
assert "async function fetchLiveSnapshot(revisions = {}, { signal } = {})" in html
assert "headers: { Accept: 'application/json' },\n signal," in html
assert "function load() { return contextPoller.refresh({ force: true }); }" in html
assert "contextPoller.refresh({ force: true });" in html
@pytest.mark.anyio
async def test_stalled_refresh_keeps_the_last_snapshot_with_specific_guidance():
html = await dashboard()
assert "e.name === 'TimeoutError'" in html
assert "Update delayed · showing last snapshot" in html
+131
View File
@@ -0,0 +1,131 @@
import json
import subprocess
from pathlib import Path
from tests.dashboard_bundle import dashboard_bundle_text
OVERLAY_HISTORY = Path(__file__).parents[1] / "frontend" / "task-overlay-history.js"
def test_task_overlay_history_unwinds_preview_search_and_dashboard_without_duplicate_entries():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const location = {{ hash:'', pathname:'/dashboard/' }};
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const changes = [];
const history = {{
get state() {{ return stack[cursor]; }},
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
back() {{ if (cursor > 0) cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
history, location,
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
controller.open('search');
controller.open('search');
controller.open('search-preview');
controller.close();
controller.close();
process.stdout.write(JSON.stringify({{changes, stack, cursor, current:controller.current()}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"changes": [
["search", None],
["search-preview", "search"],
["search", "search-preview"],
[None, "search"],
],
"stack": [
{"page": "dashboard"},
{"page": "dashboard", "taskOverlay": "search"},
{"page": "dashboard", "taskOverlay": "search-preview"},
],
"cursor": 0,
"current": None,
}
def test_task_overlay_history_rejects_unknown_states_and_preserves_existing_history_state():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const pushed = [];
const history = {{
state: {{ workRoute:'#/my-work/issue/stackchain/api/7' }},
pushState(state) {{ this.state = state; pushed.push(state); }}, back() {{}},
}};
const controller = createTaskOverlayHistory({{
history, location:{{}}, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
const invalid = controller.open('settings');
const valid = controller.open('new');
process.stdout.write(JSON.stringify({{invalid, valid, pushed}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"invalid": False,
"valid": True,
"pushed": [{
"workRoute": "#/my-work/issue/stackchain/api/7",
"taskOverlay": "new",
}],
}
def test_task_overlay_history_can_leave_an_overlay_for_a_work_route_transition():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const changes = [];
const history = {{
state: {{ page:'dashboard' }},
pushState(state) {{ this.state = state; }},
replaceState(state) {{ this.state = state; }},
back() {{ throw new Error('route transitions must not race history.back'); }},
}};
const controller = createTaskOverlayHistory({{
history, eventTarget:{{addEventListener() {{}}}},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.open('find');
const left = controller.leave();
process.stdout.write(JSON.stringify({{left, state:history.state, current:controller.current(), changes}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"left": True,
"state": {"page": "dashboard"},
"current": None,
"changes": [["find", None], [None, "find"]],
}
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
html = dashboard_bundle_text()
assert '<script src="static/task-overlay-history.js"></script>' in html
assert "createTaskOverlayHistory({" in html
assert "taskOverlayHistory.open('new')" in html
assert "taskOverlayHistory.open('find')" in html
assert "taskOverlayHistory.open('search')" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "taskOverlayHistory.close()" in html
assert "saveIssueCaptureDraft();" in html
assert "history.replaceState(history.state || {}, '', cleanUrl)" in html
+113
View File
@@ -0,0 +1,113 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
def run_node(script):
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
let login = 'timmy';
const queue = createTodayWork({{storage, getLogin: () => login, limit: 3}});
const issue = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const first = queue.add(issue(1));
const duplicate = queue.add(issue(1));
queue.add(issue(2));
queue.add(issue(3));
const full = queue.add(issue(4));
queue.move(issue(3), 'up');
queue.move(issue(3), 'up');
const timmy = queue.reconcile([issue(1), issue(2), issue(3), issue(4)]).map(item => item.number);
const persisted = createTodayWork({{storage, getLogin: () => login, limit: 3}})
.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
login = 'alexander';
const isolated = queue.reconcile([issue(1), issue(2), issue(3)]).map(item => item.number);
process.stdout.write(JSON.stringify({{first, duplicate, full, timmy, persisted, isolated}}));
"""
assert json.loads(run_node(script)) == {
"first": "added",
"duplicate": "exists",
"full": "full",
"timmy": [3, 1, 2],
"persisted": [3, 1, 2],
"isolated": [],
}
def test_today_queue_exposes_reorder_boundaries_for_touch_controls():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.get(key) || null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const queue = createTodayWork({{storage, getLogin: () => 'timmy'}});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number}});
queue.add(item(1)); queue.add(item(2)); queue.add(item(3));
process.stdout.write(JSON.stringify([
queue.position(item(1)), queue.position(item(2)), queue.position(item(3)), queue.position(item(9))
]));
"""
assert json.loads(run_node(script)) == [
{"can_up": False, "can_down": True},
{"can_up": True, "can_down": True},
{"can_up": True, "can_down": False},
{"can_up": False, "can_down": False},
]
@pytest.mark.anyio
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
html = await dashboard()
assert '<script src="static/today-work.js"></script>' in html
assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" in html
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
assert 'data-today-add' in html
assert 'data-today-remove' in html
assert 'data-today-move="up"' in html
assert 'data-today-move="down"' in html
assert 'Today is limited to 5 items' in html
assert '.today-actions button' in html and 'min-height:44px' in html
@pytest.mark.anyio
async def test_retained_authenticated_context_keeps_local_planning_separate_from_fresh_delivery():
html = await dashboard()
assert "let planningOwnerLogin = '';" in html
assert html.count("getLogin: () => planningOwnerLogin") == 2
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
assert "planningOwnerLogin = retainedPlanningLogin;" in html
assert "button.disabled = !planningOwnerLogin;" in html
assert "activeFlushLogin = contextIdentityFresh ?" in html
assert "getOwnerLogin: () => confirmedOwnerLogin" in html
assert "Planning is unavailable until your operator identity is restored." in html
assert "Could not save Today on this device." in html
assert "Could not save Later on this device." in html
assert "data-planning-disabled" in html
+111
View File
@@ -0,0 +1,111 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.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_unfiled_captures_keep_twenty_newest_account_bound_notes():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let sequence = 0;
const captures = createUnfiledCaptures({{
storage,
getCaptureLogin:()=>'timmy',
getCurrentLogin:()=>'',
createId:()=>String(++sequence),
now:()=>1000 + sequence,
}});
for (let index = 1; index <= 22; index += 1) {{
captures.save({{title:'Note ' + index, body:'Context ' + index}});
}}
const offline = captures.list();
const restored = createUnfiledCaptures({{
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
}}).list();
process.stdout.write(JSON.stringify({{offline, restored}}));
"""
output = run_node(script)
assert len(output["offline"]) == 20
assert output["offline"][0]["title"] == "Note 22"
assert output["offline"][-1]["title"] == "Note 3"
assert all(item["quarantined"] for item in output["offline"])
assert all(not item["quarantined"] for item in output["restored"])
assert output["restored"][0]["ownerLogin"] == "timmy"
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
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 id = 0;
const captures = createUnfiledCaptures({{
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id
}});
const first = captures.save({{title:'First',body:'One'}});
const second = captures.save({{title:'Second',body:'Two'}});
let mismatch = '';
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
const resumed = captures.resume(first.id, 'timmy');
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
"""
output = run_node(script)
assert output["mismatch"] == "Reconnect with the account that saved this capture."
assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []}
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}});
const errors = [];
for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{
try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }}
}}
process.stdout.write(JSON.stringify({{errors,size:values.size}}));
"""
output = run_node(script)
assert output == {
"errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."],
"size": 0,
}
@pytest.mark.anyio
async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow():
html = await dashboard()
assert '<script src="static/unfiled-captures.js"></script>' in html
assert 'id="save-unfiled-issue"' in html
assert 'Save for filing' in html
assert "createUnfiledCaptures({" in html
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
assert "unfiledCaptures.save(captureDraft)" in html
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "issueCapture.saveDraft(resumed)" in html
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '@media(max-width:320px)' in html
+16 -3
View File
@@ -1,3 +1,5 @@
from pathlib import Path
import pytest
from src.views import dashboard
@@ -21,15 +23,26 @@ async def test_dashboard_viewport_allows_mobile_zoom():
assert "user-scalable=no" not in html
@pytest.mark.anyio
async def test_dashboard_bootstrap_uses_csp_compatible_static_assets():
html = await dashboard()
assert '<link rel="stylesheet" href="static/dashboard.css" />' in html
assert '<script src="static/dashboard.js"></script>' in html
assert "<style>" not in html
assert "<script>" not in html
@pytest.mark.anyio
async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
html = await dashboard()
css = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css").read_text()
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
assert 'aria-labelledby="search-preview-title"' in html
assert 'id="claim-search-result"' in html
assert 'id="open-search-result-gitea"' in html
assert 'src="static/search-preview.js"' in html
assert ".search-preview-panel" in html
assert "height:100dvh" in html
assert "env(safe-area-inset-bottom)" in html
assert ".search-preview-panel" in css
assert "height:100dvh" in css
assert "env(safe-area-inset-bottom)" in css
+1 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from src.views import dashboard
from tests.dashboard_bundle import dashboard
WIDGETS = Path(__file__).parents[1] / "frontend" / "widgets.js"
+6 -3
View File
@@ -1,3 +1,5 @@
from pathlib import Path
import httpx
import pytest
@@ -98,9 +100,10 @@ async def test_dashboard_announces_offline_mode_and_refreshes_after_reconnect():
assert 'id="offline-status"' in response.text
assert 'role="status"' in response.text
assert 'aria-live="polite"' in response.text
assert "window.addEventListener('offline'" in response.text
assert "window.addEventListener('online'" in response.text
assert "contextPoller.refresh()" in response.text
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert "window.addEventListener('offline'" in bootstrap
assert "window.addEventListener('online'" in bootstrap
assert "contextPoller.refresh({ force: true })" in bootstrap
@pytest.mark.anyio