Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.
Go to file
timmy 8c82644515
All checks were successful
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
feat: complete Updates discovery before triage (Closes #749)
2026-08-13 15:28:17 +00:00
.gitea/workflows ci: gate RCs on verifiable artifacts (#339) 2026-08-08 20:35:52 +00:00
creative-deliverables Add creative deliverable for stackchain/stackchain-dashboard#5 2026-07-08 16:42:49 +00:00
docs feat: serialize autonomous release runs (#335) 2026-08-08 19:54:20 +00:00
frontend feat: complete Updates discovery before triage (Closes #749) 2026-08-13 15:28:17 +00:00
scripts ci: gate RCs on verifiable artifacts (#339) 2026-08-08 20:35:52 +00:00
src feat: complete Updates discovery before triage (Closes #749) 2026-08-13 15:28:17 +00:00
tests feat: complete Updates discovery before triage (Closes #749) 2026-08-13 15:28:17 +00:00
.gitignore feat: persist authored action idempotency (#201) 2026-08-07 14:37:10 +00:00
conftest.py feat: bind operator mode to public origin (Closes #609) 2026-08-12 01:54:51 +00:00
README.md feat: configure deadline reminder time in device setup (Closes #715) 2026-08-13 06:57:39 +00:00
requirements.txt feat: notify mobile operators of unread updates (Closes #549) 2026-08-11 07:07:52 +00:00

stackchain-dashboard

Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.

Local quickstart

Python 3.11 or newer is recommended. Create an isolated environment and install the pinned dependencies:

python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -r requirements.txt

Point the dashboard at the Gitea server root (without /api/v1) and provide a token that can read dashboard data, update the authenticated user's notification threads, create and self-assign issues, discover, claim, and release issue assignments, list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues, inspect/comment on assigned pull requests, merge assigned pull requests, and submit pull-request reviews. Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged. For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload failures keep both the typed comment and removable preview available for retry. Offline screenshot comments admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate either stage. The mobile New issue capture-first stage accepts the same image formats before a repository is chosen. Save to Drafts durably writes the optimized Blob to IndexedDB before confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact preview when the operator later chooses a repository. The source Draft remains available until its screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob. Repository-aware durable admission likewise stores the screenshot with its account-bound outbox capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing queued base64 screenshot payloads remain readable and are converted only at delivery time. Delivery creates the issue exactly once, then uploads and comments with the image; after a partial failure, retry resumes with the confirmed issue instead of creating a duplicate. Pull-request replies and mobile My Work issue and PR comments use Gitea's issue-comment API. In issue, pull-request, and unread-update conversations, typing at least two characters after @ offers repository-scoped teammate suggestions; touch or keyboard selection inserts the login without leaving the draft. Mention lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue creation and assignment permission. A fresh capture never silently targets the first repository: the operator must explicitly choose one, either from the paginated browser or through the bounded authenticated repository search. Search results include only visible repository identities, stale responses cannot replace a newer query, and search failure leaves the draft and browse fallback intact. Saved drafts restore their exact repository even when it is outside the first page. Once a repository and meaningful title are selected, the New issue sheet checks for similar open issues in that repository. Candidate links keep the draft intact; the first create attempt pauses until the operator reviews them or explicitly chooses Create anyway. This check is advisory and never blocks offline capture or capture when search is unavailable. The sheet can also optionally select an open repository milestone and due date; the dashboard validates both and sends them with self-assignment in the single create request, so planned work appears in its release lane immediately. 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. The ordered, five-item Today plan syncs across the operator's devices. Plan Today also stores available minutes and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work, and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through Plan Today first previews its Gitea dependencies: unresolved blockers are listed with links and require the explicit Add blocked item anyway override, while an unavailable dependency lookup is reported as unknown rather than unblocked. Starting a Today work session also stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. End session stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and Save recap & adjust plan continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked Log Xm to Gitea control. Log selected time to Gitea saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses Save plan or Save & start. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening Plan Today resumes it without reposting the recap; a successful plan save clears it, while Discard recap feedback removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap. After a reload or installed-app restart, Resume Today reopens the saved item (or the next surviving item if work changed); Comment & next on that current issue or pull request posts the handoff online or admits it to durable account-bound delivery, then removes the item only from Today and opens the next one without closing or merging it. Reply & next provides the same one-action continuation for the current unread-update conversation. It deliberately leaves the notification unread; an open, unassigned issue update also offers Take ownership & start, which checks Today capacity before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints the session, and opens the issue. The adjacent Take ownership action remains available for claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance. Mark read & next remains the explicit acknowledgement path. Delivery or local-admission failure preserves both the reply draft and checkpoint. Finishing or choosing End session clears only the checkpoint and leaves the Today plan unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots, and reconnecting or returning to the dashboard refreshes server truth after replaying queued offline operations. Planning edits can remain offline for up to 30 days. After that, the expired edit is discarded visibly and the account plan is kept rather than replaying stale intent. The server retains no more than 4,096 operation receipts per account and removes receipts older than the same 30-day window; client base revisions keep a pruned replay from changing a newer Today or Later plan. After a healthy, fully paginated My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain queues an idempotent retirement before removing it locally; partial and degraded refreshes leave the plan unchanged, and offline retirements replay after reconnect. When Keep My Work available offline is enabled, every issue or pull request in the bounded Today queue is warmed automatically after a healthy authenticated refresh and as soon as it is added. The readiness indicator reports saved, pending, and retryable items; unchanged updated_at revisions make no detail request, transient failures retain the prior copy, and pull-request diffs remain online-only. Saved unread-update conversations remain triageable offline: Queue read & next writes an account-bound, notification-ID- deduplicated acknowledgement to the durable background delivery system, removes the update from the local queue immediately, and opens the next saved conversation. A cold offline reload suppresses acknowledgements still waiting to sync; reconnect uses the authenticated notification-read endpoint and keeps transient failures queued. Every foreground same-origin dashboard API request also has a 15-second browser deadline, including fresh- authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are not changed by this session boundary. Installed-app navigations are also deadline-bounded: after four seconds without a network response, Stackchain aborts the request and opens the cached dashboard shell. If the shell has not been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging. Live dashboard refreshes use the server's per-section freshness windows: healthy sections refresh at their earliest deadline while a fully degraded snapshot waits for its reported cooldown. HTTP Retry-After delays are honored up to five minutes, and transport failures back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit refresh still run immediately; a successful response resets transport backoff. Closed-app delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records and runs up to three deliveries concurrently, but claims a record only when a delivery slot is ready. Claims have unique fencing tokens and are renewed before every network stage; completion, release, failure, and delivery checkpoints are token-fenced so an expired worker cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing private outbox storage. Results are coordinated through a bounded SQLite ledger. Set STACKCHAIN_STATE_DIR to a persistent, writable service directory (or set STACKCHAIN_IDEMPOTENCY_DB to an explicit SQLite path); the local default is .stackchain-state/idempotency.sqlite3. Ledger reads and writes run outside the request event loop, and lock admission is bounded to 100 ms by default. Tune it with STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS; keep the value below route deadlines. During a controlled shutdown, the server gives in-flight authored mutations five seconds to finish and persist their ledger result before it closes the Gitea transport. Set STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS to match the service manager's shutdown budget; when that deadline expires, remaining operations are cancelled and their pending reservations continue to fail closed rather than being retried automatically. Reservation contention returns retryable HTTP 503 with Retry-After: 1. If contention occurs after the upstream mutation, the dashboard fails closed with Retry-After: 5 and asks the caller to verify the result before retrying. Direct API callers should preserve the Idempotency-Key header with the unchanged route and payload until a 201 response is confirmed. Closing an assigned issue, native Comment, Approve, and Request changes reviews, and assigned-PR merge require repository write permission. Native Comment, Approve, and Request changes reviews support head-scoped draft comments anchored to changed lines; the dashboard validates each comment path and submits the summary, decision, and inline comments in one review request. On phone-width review sheets, changed lines wrap inside a stable old/new line-number gutter by default so long source lines remain readable and commentable without horizontal panning. Lines wrapped toggles back to whitespace-preserving horizontal inspection, and an explicit choice persists on the device across files, review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft state, and mergeability immediately before every merge. Serve the dashboard only to trusted users on its own origin; cross-origin API access is intentionally disabled. Authentication defaults to fail-closed operator mode. It requires two independent secrets of at least 24 characters; missing, short, reused, or invalid configuration leaves /healthz available but returns HTTP 503 before any Gitea access. The access token is entered at /login; the browser receives only a short-lived signed session and CSRF proof. Generate both values in your secret manager (for example, openssl rand -hex 32) and inject them at runtime—never commit them:

export GITEA_URL='https://forge.example.com'
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
# Optional compatibility overrides; defaults derive from the required public origin.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
# Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
# Background polling and queued delivery do not. Defaults to 15 minutes.
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
export STACKCHAIN_LOGIN_MAX_FAILURES=5
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
# Public passkey ceremonies share the same window and durable source ledger.
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
# Bound live one-time challenges even if anonymous clients rotate addresses.
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
# The feature stays disabled unless all three values are present. Privacy-safe update
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
# within each device; one slow device does not delay healthy devices behind it.
export STACKCHAIN_PUSH_POLL_SECONDS=30
# Unread updates and deadline reminders run on independent, fixed-cadence
# workers, so a slow channel cannot delay the other or add drift to its ticks.
# On each phone, Device Setup can enable deadline reminders and choose any local
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
# Maximum individual alerts per device and poll before one digest covers the rest.
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
export STACKCHAIN_PUSH_LEASE_SECONDS=60
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
uvicorn src.main:app --host 127.0.0.1 --port 8000

Token and passkey sign-in failures are scoped to a hashed canonical client address and persisted across workers and restarts. Public passkey option issuance has a separate fixed-window admission budget in the same ledger, and live challenges are bounded per source and globally in the session registry. Once either sign-in budget is exhausted, the server returns 429 with Retry-After; a successful sign-in clears that source's failure state. Expired source records and challenges are pruned, and both ledgers are size-bounded. Keep their SQLite files on shared writable storage. X-Forwarded-For is ignored unless the immediate peer is inside STACKCHAIN_TRUSTED_PROXY_CIDRS; list only networks you operate. Without that setting, a reverse proxy is safely treated as one shared source.

Inbound API mutations are admitted through a body-size boundary before FastAPI parses JSON: sign-in is capped at 16 KiB and other POST, PUT, and PATCH requests under /api/v1/ are capped at 64 KiB. Both declared and streamed bodies are counted. Oversized requests receive a compact, non-cacheable HTTP 413 response; validation errors expose field locations and messages but never echo submitted values or validation context. Preserve these limits at the reverse proxy or enforce equal or tighter upstream limits.

Each signed cookie includes an opaque session identifier whose hash and expiry are kept in the SQLite session registry. Keep that registry on persistent, writable storage shared by all dashboard workers. Operators name a device at sign-in and can open Active devices to review creation/expiry times, identify the current device, and revoke one remote session without interrupting other trusted devices. The API exposes only independent management IDs and bounded labels—never cookie values, session hashes, CSRF proofs, or source addresses. The registry also stores each session's last explicit activity. Existing two-column registries are migrated in place, their live sessions remain valid, and their idle clock starts at migration.

The same sheet includes Security activity, a reverse-chronological journal of successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device revocations, issue closures, and pull-request merges. The separate SQLite journal retains at most 10,000 events for 90 days and stores only bounded device labels and action targets. It never stores access tokens, cookies, session/CSRF values, credential IDs, public keys, challenges, attestation data, raw network addresses, request bodies, or comment content. Keep its database on the same class of persistent, writable storage as the session registry. Before passkey credential creation, session revocation, or issue closure, the journal durably reserves a pending event; if that reservation fails, the consequential action does not begin. A successful action remains truthfully reported even if its event cannot immediately be finalized, and the activity sheet marks that durable record as Outcome confirmation pending.

After token bootstrap, Active devices → Add a passkey for this device enrolls a WebAuthn credential with required user verification. That device can then sign in and authorize high-impact actions with its biometric/PIN gesture. The access token remains the recovery fallback for browsers without WebAuthn or devices without an enrolled credential. Registration and authentication challenges are exact-purpose, single-use, and short-lived. Enrolled passkeys lists every durable credential, including those whose original session has expired or signed out, using only its bounded label, enrollment time, and active/current status. Removing one requires a single-use fresh authorization bound to that exact management ID. A remote active session linked to the credential is revoked atomically; unrelated credentials and sessions remain valid. Removing the current device's passkey keeps its current session active, so the sheet warns that the recovery token will be required after sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out normally keeps the passkey available for the next sign-in. Passkey assertion counters advance with an atomic compare-and-swap across workers: stale or equal nonzero counters fail before a session or authorization grant is issued, while counterless authenticators remain compatible. The bounded Passkey counter anomaly Security activity entry names only the device label and attempted action; repeated alerts should prompt removal and re-enrollment of that passkey.

High-impact actions—merging a pull request, closing an assigned issue, revoking a remote device, or signing out every device—require a passkey assertion or the operator access token again. The server issues a random 90-second grant bound to the active session, exact action, and exact target. Only its digest is stored, and the grant is consumed atomically on first use. Expired, replayed, cross-session, and target-substituted grants fail before Gitea or session state is changed. The browser preserves the pending request and retries it once after the built-in mobile/keyboard-accessible authorization prompt.

After 15 minutes without pointer, keyboard, or touch activity (configurable through STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS), the server rejects the session even if polling or background delivery continued. The dashboard locks and background outbox delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on the device; signing in resumes the existing account-bound work. If an active session expires, the first authenticated API rejection replaces the dashboard with sign-in and explains that private drafts remain on the device; signing in again resumes account-bound queued delivery. Expiry recovery does not clear offline state. A selectively or globally revoked device instead receives a bounded revocation reason: on its next server contact, Stackchain clears its owned local/session storage, private outbox database, and dashboard caches before enabling sign-in. Revocation blocks server access immediately, but no web application can erase a device that remains offline forever. Sign out & clear this device revokes only the current session before clearing browser state, so a copied cookie cannot be replayed afterward; other signed-in devices remain active. Sign out all devices is a separately confirmed lost-device safety action that atomically revokes every existing operator session before clearing the current browser and returning to sign-in. Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted.

Terminate TLS at the trusted reverse proxy: session cookies are deliberately Secure, HttpOnly, SameSite=Strict, and scoped to the deployment subpath. Every response also defines the browser execution boundary with a Content Security Policy that allows scripts only from the dashboard origin, denies framing and plugins, and blocks unused browser capabilities. Keep these response headers when proxying; do not add inline scripts or broaden script-src. The dashboard bootstrap is assembled in source order into one content-addressed JavaScript response. Dashboard HTML and the offline worker reference that exact fingerprint, while the runtime receives immutable caching and HTML/worker responses remain revalidated. The stylesheet and fingerprinted runtime are same-origin assets included atomically in the offline PWA shell. Use Sign out & clear this device on shared devices; it clears Stackchain's offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without removing unrelated forge preferences. Rotate either dashboard secret by replacing the injected value and restarting the service; changing the signing secret expires all sessions immediately. The signing key is never derived from the access token.

For direct, unproxied local development only, set STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local and open http://127.0.0.1:8000/. This mode checks the network peer and rejects non-loopback clients; do not use it behind a reverse proxy, whose loopback connection would hide the browser's peer address. To verify the backend and its Gitea connection in operator mode, sign in and request http://127.0.0.1:8000/api/v1/context; a successful response is JSON containing user, repos, issues, and pull_requests. Press Ctrl/Cmd+K in the dashboard to search commands plus issues and pull requests across every repository visible to the configured Gitea token. Remote search starts after two characters, is debounced, and keeps local commands usable if Gitea search is unavailable. Selecting a remote result opens a mobile-safe preview without discarding the search query. Open unassigned issues can be claimed in place and handed into My Work after Gitea confirms the assignment. A closed issue can be reopened, self-assigned, added to Today, and resumed through the same capacity-guarded flow; pull requests remain read-only with a safe canonical Gitea link. The bounded APIs are available at GET /api/v1/search?q=<query>&limit=<1-25>, GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull, and PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen. Never commit the token or place it in a tracked configuration file.

For service monitoring, GET /healthz is a liveness check that confirms the API process is running and does not contact Gitea. GET /readyz is the readiness check: it validates the configured Gitea credentials and returns HTTP 503 with an error when Gitea is unavailable or authentication fails.

The application-lifetime Gitea transport bounds aggregate upstream traffic and coalesces identical concurrent GETs without caching completed responses. By default, at most eight requests run at once, with one slot reserved for authored mutations so polling and detail-read bursts cannot starve comments, reviews, or other writes. Requests that cannot enter within 250 ms fail as retryable HTTP 503 responses. Tune these limits with GITEA_MAX_CONCURRENCY (minimum 2) and GITEA_ADMISSION_TIMEOUT_SECONDS (minimum 0.001); keep the admission timeout below the route deadlines. Streaming diff reads share the same read capacity, while POST, PATCH, PUT, and DELETE requests are never coalesced.

Live snapshots expose independent context, events, and notifications revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published atomically through a private SQLite store shared by all application workers. An expiring refresh lease ensures only one worker loads currently due sections; other workers serve the same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry. Set STACKCHAIN_LIVE_SNAPSHOT_DB to override the default STACKCHAIN_STATE_DIR/live-snapshot.sqlite3; keep the containing directory on private, worker-shared writable storage. The database and directory are restricted to the service account and never contain the Gitea token. Find Work uses the same worker-shared pattern: STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB overrides STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3. One expiring lease bounds each catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry bursts, and confirmed claims are removed from every worker's retained catalog. On a cold catalog, non-owner workers wait within the bounded foreground deadline for the lease owner to publish; if that owner releases or abandons the lease, a waiter can take over the scan within its remaining request budget. Releasing an assignment invalidates the shared catalog so the newly available issue can be discovered by the next authoritative scan. Each bounded opaque revision token includes the store generation, so a token from a different deployment or before replacement of the store cannot suppress different content. The browser sends its known tokens on later polls, so /api/v1/live can omit unchanged section bodies while still returning current freshness and retry metadata. The client retains omitted data and only rebuilds or persists the sections that changed. Malformed or oversized tokens are rejected before any upstream work. Confirmed notification acknowledgements update this worker's live view immediately, then maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove all successful notification IDs in one SQLite transaction and advance the notification revision once. If shared-store lock admission fails after Gitea confirms the mutation, the API keeps the confirmed result and process-local filtering rather than falsely reporting the upstream write as failed; a later shared refresh reconciles the cache.

Offline mobile shell

At phone widths, a persistent bottom task dock keeps Work, Find, New, Search, and Drafts thumb-reachable. It reuses the existing sheets and My Work filter, preserves the selected release lane, shows the current draft count, respects the device safe area, and moves out of the way while a full-screen task is open. Desktop layout is unchanged.

My Work also has an account-synced Later queue. Later today defers an item for four hours, while Tomorrow returns it at 09:00 in the device's local timezone. Choose date & time accepts a valid future local date and time and returns the item at that exact instant; the picker shows the device timezone and rejects empty, normalized, invalid, or past values before saving. Deferred items leave normal and Attention queues without marking notifications read or changing any Gitea issue or pull request. They automatically return to their existing priority position at the wake time. Start now atomically admits a deferred item to Today, removes its Later record, and opens that exact item in a resumable Today session; full or unavailable Today storage leaves the deferral intact for retry. Items already in Today are opened without duplication. Bring back now still restores an item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize across signed-in tabs and devices. Offline changes apply immediately, survive reload, and replay after reconnect. Each queued edit carries the account revision it was based on; if another device has since changed the same item, Stackchain keeps the newer account plan and reports the conflict instead of silently overwriting it. Unrelated items continue syncing in the same batch. STACKCHAIN_LATER_DB can override the default durable store at .stackchain-state/later.sqlite3.

After one successful online load, the installed dashboard precaches a versioned, subpath-scoped application shell. During a network outage or a dashboard HTTP 500, 502, 503, or 504 response, navigation falls back to that shell when it is available so local issue and review drafts remain reachable. Authentication and other client-error responses are never hidden. A visible, accessible offline or server-outage notice distinguishes cached mode from live Gitea data, and the dashboard keeps polling until it can restore its live snapshot automatically.

Users can explicitly enable Keep My Work available offline. Each healthy live refresh then stores a seven-day, versioned snapshot containing only the signed-in user identity and queue-card metadata for issues, pull requests, unread updates, and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and newest 20 comments. Requested-review records also retain the head SHA, CI state, newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines per file. A previously opened unread update is retained by notification identity with allowlisted subject context and its newest 20 conversation messages. The snapshot and each account-bound detail are committed as separate IndexedDB records, so large review previews do not consume the small synchronous localStorage quota or require rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1 localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains limited to ten records across all detail kinds; credentials, repository catalogs, events, raw patches, and complete API responses are excluded. A cold offline launch labels the saved time. Cached Today details open in the existing phone sheet, where comments can enter the account-bound durable outbox. For issue and non-review pull details, planning, assignment, review, merge, and close controls remain disabled until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes, summary, decision, and inline-comment drafts remain usable under the saved head SHA. Queue review for reconnect durably stores that complete SHA-bound review with one idempotency key. Reconnect or Background Sync submits it exactly once; transient failures retain the queue entry, while stale-head and invalid-inline-comment responses move it to Drafts as Needs attention without deleting feedback. Confirmed delivery removes the queued operation and its matching SHA-scoped draft and progress. Cached unread updates use the same phone conversation sheet and replies enter the account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection. Cards without a saved detail explain that reconnection is required. Clear offline work data, opt-out, sign-out, remote revocation, and absolute session expiry delete the IndexedDB records; seven-day expiry removes stale records automatically.

API responses and mutations are never cached by the service worker. New issue captures, issue comments, pull-request comments, and unread-update replies use bounded local outboxes when connectivity or a retryable server failure prevents delivery. Issue captures and authored messages are also mirrored into account-bound IndexedDB lanes and registered with Background Sync, so a supporting installed browser can deliver new issues, issue comments, pull-request comments, unread-update replies, and completed pull-request reviews after every dashboard client has closed. The worker verifies the current Gitea login, shares an atomic delivery claim with the foreground path, and preserves the original idempotency key. Installed browsers can explicitly enable Notify me when queued work finishes; permission is requested only from that user gesture and the choice is stored for the confirmed account in the private background outbox database. Successful deliveries produce privacy-safe receipts that open the created issue or source conversation, while permanent validation failures open Drafts for recovery. Authentication expiry during a worker mutation is recoverable: the worker releases the claim without creating an Attention receipt, stops that drain, and keeps the original idempotency key. Loading the authenticated dashboard after signing in asks the worker to resume queued delivery automatically. Notification text never includes issue titles, comment bodies, or validation details. The preference is off by default, unsupported or denied browsers retain foreground reconciliation, and Sign out & clear this device removes the account-bound choice. Browsers without IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows queued and needs-attention messages with explicit send/discard controls; reopening the dashboard reconciles worker completions and permanent failures into the visible outbox. Foreground delivery flushes messages sequentially with their original idempotency keys. Tabs coordinate each queue through the browser lock manager (with an expiring local lease fallback), so an installed PWA and a browser tab cannot submit the same operation concurrently. Queue changes are broadcast to other tabs and refresh Drafts without polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such as reviews, merges, closures, labels, milestones, and assignments are never queued. Service-worker upgrades are atomic and remove only older stackchain-dashboard-* caches, preserving unrelated caches on the same origin.

Run the test suite with:

python3 -m pytest tests/ -q

Autonomous release worker

The deterministic issue-to-release engine lives at src/release_engine.py. It discovers or targets one Gitea issue, verifies its claim, creates an issue branch, invokes a configured coding agent, runs tests, pushes, and opens a linked pull request. Merge remains gated by CI/review; the existing release workflow drafts the release candidate after merge.

Plan against live Gitea without mutating anything:

python3 -m src.release_engine \
  --repo stackchain/stackchain-dashboard \
  --agent timmy \
  --issue 14 \
  --dry-run

Execute one ticket after implementing and committing its change directly on the deterministic branch printed by --dry-run:

export GITEA_TOKEN='<scoped-token>'
export RELEASE_AGENT_COMMAND=true
python3 -m src.release_engine \
  --repo stackchain/stackchain-dashboard \
  --agent timmy \
  --issue 19 \
  --agent-timeout 1800 \
  --test-timeout 900 \
  --termination-grace 5 \
  --agent-env AGENT_CONFIG_HOME \
  --test-command 'python3 -m pytest tests/ -q'

GITEA_TOKEN needs issue and repository write scopes. The engine verifies the 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.

Every child process receives an explicit least-privilege environment rather than inheriting the worker's credentials. Coding agents receive a minimal runtime environment plus the five RELEASE_* context values above. Tests and Git receive only the minimal runtime environment, so GITEA_TOKEN and unrelated parent secrets remain in the release-engine process. If an agent runtime needs another non-secret setting, allow it explicitly with a repeated --agent-env NAME option or the comma-separated RELEASE_AGENT_ENV variable. GITEA_TOKEN, reserved RELEASE_* names, and malformed names are rejected before the issue is claimed. Secret-bearing values must not be allowlisted.

Agent and test deadlines are independent. On expiry, the engine terminates the command's entire process group, escalates from SIGTERM to SIGKILL after the configured grace period, and records agent_timed_out or tests_timed_out without advancing the release. The equivalent environment variables are RELEASE_AGENT_TIMEOUT, RELEASE_TEST_TIMEOUT, and RELEASE_TERMINATION_GRACE; every value must be positive.

Durable state defaults to .release-engine/state.json. Successful checkpoints record the repository, issue branch, and exact commit SHA. A restart resumes at tests, push, or PR creation only after validating that identity and a clean worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations are serialized by an OS-backed, non-blocking lease at .release-engine/run.lock under the canonical repository path. If an hourly run overlaps a manual or slow prior run, the contender exits with release run already active and owner diagnostics before Gitea discovery or any Git/agent work. The kernel releases ownership when the process exits, so never delete a lock file to recover from a crash; stale file metadata cannot retain the lease. Checkpoint replacement uses writer-unique, flushed temporary files and an atomic rename, preventing concurrent writers from colliding or exposing partial JSON. Full behavior and safety gates are documented in docs/release-engine-spec.md.